Skip to content
Merged
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
7cdf1c6
fix(git): stop background status reads from locking the index
Zeus-Deus Nov 13, 2026
06f912b
perf(git): count changed files without re-running the diffs
Zeus-Deus Nov 13, 2026
6b5784c
fix(git): keep the background fetch out of the user's way
Zeus-Deus Nov 13, 2026
efe5c08
perf(git): refresh a new worktree's index once after creating it
Zeus-Deus Nov 13, 2026
7497fc7
fix(diff): cap a file's diff so a huge one can't freeze the pane
Zeus-Deus Nov 13, 2026
9e507c4
fix(claude): end the CLI when Stop is never acknowledged
Zeus-Deus Nov 13, 2026
a6e786c
fix(rpc): fail pending requests when a child closes stdout but keeps …
Zeus-Deus Nov 13, 2026
661b6a0
fix(acp): settle cancelled tool calls and plan steps
Zeus-Deus Nov 13, 2026
e0d3e2d
fix(codex): fail a turn whose provider retry never resumes
Zeus-Deus Nov 13, 2026
ecf3570
fix(web-remote): sandbox SVG and HTML assets opened as documents
Zeus-Deus Nov 13, 2026
ac16cca
fix(automations): strip credentials from project remote URLs
Zeus-Deus Nov 13, 2026
f246fee
fix(github): resolve GitHub SSH host aliases to github.com
Zeus-Deus Nov 13, 2026
b2eac8d
fix(pr-watch): wait for queued workflow runs before reporting green
Zeus-Deus Nov 13, 2026
6ac4890
fix(ssh): keep retrying a remote tunnel after its circuit breaker opens
Zeus-Deus Nov 13, 2026
260ac91
fix(ssh): clear a dead tunnel's socket file before reconnecting
Zeus-Deus Nov 13, 2026
953a1c9
fix(ssh): stop remote-workspace tunnels when the app quits
Zeus-Deus Nov 13, 2026
9d3bdf2
fix(remote): time out stalled reconnect attempts and restart them on …
Zeus-Deus Nov 13, 2026
c86d5d8
perf(chat): stop the right panel re-rendering on every streamed batch
Zeus-Deus Nov 13, 2026
33e90b8
fix(chat): keep auto-follow when clicking the transcript's left gutter
Zeus-Deus Nov 13, 2026
c443986
fix(claude): keep a stalled Stop from ending a newer query
Zeus-Deus Nov 13, 2026
cb55bdc
fix(diff): drain git's stderr while reading a capped diff
Zeus-Deus Nov 13, 2026
e41dd71
fix(remote): keep an abandoned grant request from replacing a newer g…
Zeus-Deus Nov 13, 2026
f659bf7
test(remote): update startup tests for socket redials and grant cance…
Zeus-Deus Nov 13, 2026
07bc711
fix(github): let host mappings override SSH alias inference
Zeus-Deus Nov 13, 2026
b7633b1
fix(pr-watch): retry a failed workflow-runs read instead of reporting…
Zeus-Deus Nov 13, 2026
dab3cc3
test(git): read the diff text from FileDiff in integration tests
Zeus-Deus Nov 13, 2026
a4a0096
fix(codex): keep the first stall deadline across repeated retries
Zeus-Deus Nov 13, 2026
724ede5
fix(ssh): stop remote-workspace tunnels when headless serve exits
Zeus-Deus Nov 13, 2026
03bc1d6
test(remote): wait for the ticket body before checking the socket
Zeus-Deus Nov 13, 2026
153dcfd
fix(github): only redirect an SSH alias where ssh config points it
Zeus-Deus Nov 13, 2026
d13c4a0
fix(pr-watch): read every workflow-run page before reporting green
Zeus-Deus Nov 13, 2026
1e1fc6d
test: find fixture binaries when Cargo moves unit-test executables
Zeus-Deus Nov 13, 2026
d8f9435
ci: find Windows test executables wherever Cargo builds them
Zeus-Deus Nov 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 60 additions & 8 deletions sidecar/claude-agent/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,20 @@ export function resetInterruptExitTimeoutForTests(): void {
interruptExitTimeoutMs = 2000;
}

/** How long `interrupt()` waits for the CLI to acknowledge the interrupt
* control request. A wedged CLI never replies, and the SDK waits forever.
* Must stay below the host's 20s RPC timeout so Stop falls back to ending
* the CLI process instead of failing. */
let interruptAckTimeoutMs = 10_000;

export function setInterruptAckTimeoutForTests(ms: number): void {
interruptAckTimeoutMs = ms;
}

export function resetInterruptAckTimeoutForTests(): void {
interruptAckTimeoutMs = 10_000;
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -403,6 +417,10 @@ export class ClaudeSession {
* an iterator exit is expected and stays silent instead of firing
* `session-ended`. */
private interrupting = false;
/** The Stop in flight, shared by concurrent `interrupt()` calls and
* awaited by `sendTurn` so a new turn never lands on a query the Stop
* is about to end. */
private stopTask: Promise<void> | null = null;
/** True once an interrupt has killed the SDK query. The next
* `sendTurn` rebuilds a resumed query via `ensureLiveQuery`. */
private queryDead = false;
Expand Down Expand Up @@ -804,6 +822,8 @@ export class ClaudeSession {
/** Enqueue a user turn on the prompt queue. Rejects if the session
* has already been closed. */
async sendTurn(input: SendTurnInput): Promise<void> {
// A failed Stop is reported to its own caller; the turn still goes.
if (this.stopTask) await this.stopTask.catch(() => undefined);
if (this.closed) {
throw new Error("session is closed");
}
Expand Down Expand Up @@ -887,12 +907,14 @@ export class ClaudeSession {
* rebuilds a resumed query, as after any interrupt that killed it. */
private async retireForStop(): Promise<void> {
this.interrupting = true;
this.retiredQueries.add(this.query);
const query = this.query;
const iterationTask = this.iterationTask;
this.retiredQueries.add(query);
this.pendingSteers.clear();
try {
try {
this.queryAbort.abort();
this.query.close();
query.close();
} catch (err) {
logger.warn("query.close during stop threw", {
threadId: this.threadId,
Expand All @@ -901,14 +923,14 @@ export class ClaudeSession {
}
let timer: ReturnType<typeof setTimeout> | undefined;
await Promise.race([
this.iterationTask,
iterationTask,
new Promise<void>((resolve) => {
timer = setTimeout(resolve, interruptExitTimeoutMs);
}),
]);
if (timer !== undefined) clearTimeout(timer);
// The process is gone either way; never reuse its query.
this.queryDead = true;
if (this.query === query) this.queryDead = true;
if (!this.closed) {
this.emitter.notification("turn-interrupted", {
threadId: this.threadId,
Expand Down Expand Up @@ -983,25 +1005,55 @@ export class ClaudeSession {
* notification (not `session-ended`) tells the host the turn is
* over but the session lives. */
async interrupt(): Promise<void> {
// Concurrent Stops share the one in flight.
if (this.stopTask) return this.stopTask;
if (this.closed || this.queryDead) return;
this.acceptsSteers = false;
this.pluginBridgeGuard.clearTickets();
if (this.pendingSteers.size > 0) return this.retireForStop();
const stop =
this.pendingSteers.size > 0 ? this.retireForStop() : this.softInterrupt();
this.stopTask = stop;
try {
await stop;
} finally {
if (this.stopTask === stop) this.stopTask = null;
}
}

/** Every step targets the query that was running when Stop began. A
* stale-resume self-heal can replace it mid-Stop, and the replacement
* must never be ended by this Stop's timers. */
private async softInterrupt(): Promise<void> {
const query = this.query;
const iterationTask = this.iterationTask;
this.interrupting = true;
try {
await this.query.interrupt();
let ackTimer: ReturnType<typeof setTimeout> | undefined;
const acked = await Promise.race([
query.interrupt().then(() => true),
new Promise<boolean>((resolve) => {
ackTimer = setTimeout(() => resolve(false), interruptAckTimeoutMs);
}),
]).finally(() => clearTimeout(ackTimer));
if (!acked && this.query === query && !this.queryDead && !this.closed) {
// The CLI never answered; end its process so Stop still wins.
logger.warn("interrupt not acknowledged; ending the CLI", {
threadId: this.threadId,
});
return await this.retireForStop();
}
// Wait for `consumeMessages` to observe the iterator exit (which
// flips `queryDead`), bounded by a timeout in case the SDK
// treated the interrupt as soft and kept iterating.
let timer: ReturnType<typeof setTimeout> | undefined;
await Promise.race([
this.iterationTask,
iterationTask,
new Promise<void>((resolve) => {
timer = setTimeout(resolve, interruptExitTimeoutMs);
}),
]);
if (timer !== undefined) clearTimeout(timer);
if (this.queryDead && !this.closed) {
if (this.query === query && this.queryDead && !this.closed) {
this.emitter.notification("turn-interrupted", {
threadId: this.threadId,
});
Expand Down
82 changes: 82 additions & 0 deletions sidecar/claude-agent/test/steer-turn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";

import {
ClaudeSession,
resetInterruptAckTimeoutForTests,
resetInterruptExitTimeoutForTests,
resetQueryFactoryForTests,
setInterruptAckTimeoutForTests,
setInterruptExitTimeoutForTests,
setQueryFactoryForTests,
type EventEmitter,
Expand Down Expand Up @@ -41,6 +43,7 @@ beforeEach(() => {
afterEach(() => {
resetQueryFactoryForTests();
resetInterruptExitTimeoutForTests();
resetInterruptAckTimeoutForTests();
_resetSessionsForTests();
});

Expand Down Expand Up @@ -100,6 +103,85 @@ test("steerTurn is rejected while an interrupt is still ending the turn", async
await session.close();
});

test("Stop the CLI never acknowledges ends the CLI process instead of hanging", async () => {
const methods: string[] = [];
const session = new ClaudeSession(input(), {
notification: (method) => methods.push(method),
});
setInterruptAckTimeoutForTests(20);
await session.sendTurn({ text: "first" });
const fake = fakes[0] as FakeQuery;
fake.interrupt = () => new Promise<void>(() => {});
await session.interrupt();
expect(fake.closed).toBe(true);
expect(methods).toContain("turn-interrupted");
// The stop finished, so the next turn may steer again on a fresh query.
await session.sendTurn({ text: "next" });
await session.steerTurn({ text: "guidance", uuid: "steer-ack" });
await settle();
expect(fakes).toHaveLength(2);
expect(fakes[1]?.capturedPrompts.map((p) => p.uuid as string | undefined)).toContain(
"steer-ack",
);
await session.close();
});

test("an unacknowledged Stop never ends a query that replaced the stopped one", async () => {
const methods: string[] = [];
const session = new ClaudeSession(
{ ...input(), resume: "stale-xyz" },
{ notification: (method) => methods.push(method) },
);
setInterruptAckTimeoutForTests(50);
await session.sendTurn({ text: "first" });
const fake = fakes[0] as FakeQuery;
fake.interrupt = () => new Promise<void>(() => {});
const stopping = session.interrupt();
// A stale-resume self-heal swaps in a new query before the deadline.
fake.emit({
type: "result",
subtype: "error_during_execution",
errors: ["No conversation found with session ID: stale-xyz"],
} as unknown as SDKMessage);
await settle();
expect(fakes).toHaveLength(2);
await stopping;
const replacement = fakes[1] as FakeQuery;
expect(replacement.closed).toBe(false);
expect(replacement.capturedOptions?.abortController?.signal.aborted).toBe(false);
expect(methods).not.toContain("turn-interrupted");
await session.close();
});

test("concurrent Stops share one stop, and a send waits for it", async () => {
const methods: string[] = [];
const session = new ClaudeSession(input(), {
notification: (method) => methods.push(method),
});
setInterruptAckTimeoutForTests(20);
await session.sendTurn({ text: "first" });
const fake = fakes[0] as FakeQuery;
let interruptCalls = 0;
fake.interrupt = () => {
interruptCalls += 1;
return new Promise<void>(() => {});
};
await Promise.all([
session.interrupt(),
session.interrupt(),
session.sendTurn({ text: "next" }),
]);
expect(interruptCalls).toBe(1);
expect(methods.filter((m) => m === "turn-interrupted")).toHaveLength(1);
// The send landed on a fresh query, never on the one the Stop ended.
await settle();
expect(fake.closed).toBe(true);
expect(fakes).toHaveLength(2);
expect(fake.capturedPrompts.map((p) => p.message.content)).not.toContain("next");
expect(fakes[1]?.capturedPrompts[0]?.message.content).toBe("next");
await session.close();
});

test("steer-turn RPC validates params and forwards to the session", async () => {
const methods = buildMethods(emit);
await methods["start-session"]?.({
Expand Down
36 changes: 36 additions & 0 deletions src-tauri/src/agent_provider/acp/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ use std::collections::{HashMap, HashSet};

use serde_json::{json, Value};

use crate::agent_provider::TaskStatus;

/// ACP initialize payload shared by every Codemux ACP driver.
///
/// `configOptions.boolean` lets newer agents advertise boolean selectors,
Expand Down Expand Up @@ -399,10 +401,44 @@ fn token(value: &str) -> String {
value.trim().to_ascii_lowercase().replace([' ', '-'], "_")
}

/// Whether a tool call `status` ends the call, and if so whether it ended in
/// error. A cancelled call never resumes, so it settles as an error.
pub fn tool_call_outcome(status: Option<&str>) -> Option<bool> {
match status? {
"completed" => Some(false),
"failed" | "cancelled" => Some(true),
_ => None,
}
}

/// Map a plan entry status. A cancelled step will not run, so it is settled
/// rather than left pending.
pub fn plan_entry_status(status: Option<&str>) -> TaskStatus {
match status {
Some("completed" | "cancelled") => TaskStatus::Completed,
Some("in_progress" | "inProgress") => TaskStatus::InProgress,
_ => TaskStatus::Pending,
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn cancelled_tool_calls_and_plan_steps_settle() {
assert_eq!(tool_call_outcome(Some("completed")), Some(false));
assert_eq!(tool_call_outcome(Some("failed")), Some(true));
assert_eq!(tool_call_outcome(Some("cancelled")), Some(true));
assert_eq!(tool_call_outcome(Some("in_progress")), None);
assert_eq!(tool_call_outcome(None), None);
assert_eq!(plan_entry_status(Some("cancelled")), TaskStatus::Completed);
assert_eq!(plan_entry_status(Some("completed")), TaskStatus::Completed);
assert_eq!(plan_entry_status(Some("inProgress")), TaskStatus::InProgress);
assert_eq!(plan_entry_status(Some("pending")), TaskStatus::Pending);
assert_eq!(plan_entry_status(None), TaskStatus::Pending);
}

#[test]
fn resolves_current_and_future_effort_values() {
let option = json!({
Expand Down
23 changes: 8 additions & 15 deletions src-tauri/src/agent_provider/acp/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use uuid::Uuid;
use crate::agent_provider::{
pricing, ApprovalDecision, CompletedItem, ContentDelta, ContextUsageTracker, CostSource,
ProviderError, ProviderKind, ProviderRuntimeEvent, ProviderSessionId, RequestId, SendOutcome,
SendTurnInput, SessionStatus, TaskSnapshotItem, TaskStatus, TasksSnapshot, ThreadId, TurnId,
SendTurnInput, SessionStatus, TaskSnapshotItem, TasksSnapshot, ThreadId, TurnId,
TurnStatus, TurnUsage,
};
use crate::json_rpc_child::{
Expand All @@ -25,9 +25,9 @@ use crate::json_rpc_child::{
use super::protocol::{
config_id_for, config_options, current_model_id, grok_auth_method,
grok_auth_reads_ambient_api_key, grok_model_effort_catalog, initialize_params,
local_xai_api_key_set, looks_unauthenticated, option_by_id, resolve_boolean_value,
resolve_effort_value, resolve_select_value, session_id, set_config_params, set_model_params,
ConfigKind, GrokModelEffortCatalog,
local_xai_api_key_set, looks_unauthenticated, option_by_id, plan_entry_status,
resolve_boolean_value, resolve_effort_value, resolve_select_value, session_id,
set_config_params, set_model_params, tool_call_outcome, ConfigKind, GrokModelEffortCatalog,
};
use super::slash_commands::{is_available_commands_update, AcpSlashCommandCache};

Expand Down Expand Up @@ -2527,16 +2527,13 @@ impl AcpSession {
});
}
"tool_call_update" => {
let terminal = update
.get("status")
.and_then(Value::as_str)
.is_some_and(|status| matches!(status, "completed" | "failed"));
if terminal {
if let Some(is_error) =
tool_call_outcome(update.get("status").and_then(Value::as_str))
{
let id = update
.get("toolCallId")
.and_then(Value::as_str)
.unwrap_or("acp-tool");
let is_error = update.get("status").and_then(Value::as_str) == Some("failed");
let content = update
.get("rawOutput")
.or_else(|| update.get("content"))
Expand Down Expand Up @@ -3747,11 +3744,7 @@ fn tasks_from_value(value: &Value) -> Option<TasksSnapshot> {
if title.is_empty() {
return None;
}
let status = match todo.get("status").and_then(Value::as_str) {
Some("completed") => TaskStatus::Completed,
Some("in_progress" | "inProgress") => TaskStatus::InProgress,
_ => TaskStatus::Pending,
};
let status = plan_entry_status(todo.get("status").and_then(Value::as_str));
Some(TaskSnapshotItem {
task_id: todo
.get("id")
Expand Down
Loading
Loading