Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
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
1 change: 1 addition & 0 deletions apps/ade-cli/src/headlessLinearServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ type HeadlessLinearServices = {
steer: (args: { sessionId: string; text: string }) => Promise<{
steerId: string;
queued: boolean;
reason?: "queue_full";
}>;
interrupt: (args: { sessionId: string }) => Promise<void>;
resumeSession: (args: {
Expand Down
11 changes: 7 additions & 4 deletions apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ describe("RightPane chat info", () => {
);
const frame = stripAnsi(result.lastFrame() ?? "");

expect(frame).toMatch(/↑\s+\d+\s+earlier/);
expect(frame).toMatch(/↑\s+\d+\s+completed/);
expect(frame).toContain("agent-07");
});

Expand All @@ -229,8 +229,9 @@ describe("RightPane chat info", () => {
const collapsedFrame = stripAnsi(collapsed.lastFrame() ?? "");

expect(collapsedFrame).toContain("+ show all (1)");
expect(collapsedFrame).toContain("▸ earlier (1)");
expect(collapsedFrame).toMatch(/↑\s+\d+\s+earlier/);
expect(collapsedFrame).toContain("▸ completed (1)");
expect(collapsedFrame).toMatch(/↑\s+\d+\s+completed/);
expect(collapsedFrame).not.toContain("x clear");
expect(collapsedFrame).not.toContain("completed-agent");

const expanded = render(
Expand All @@ -242,7 +243,9 @@ describe("RightPane chat info", () => {
width={80}
/>,
);
expect(stripAnsi(expanded.lastFrame() ?? "")).toContain("completed-agent");
const expandedFrame = stripAnsi(expanded.lastFrame() ?? "");
expect(expandedFrame).toContain("completed-agent");
expect(expandedFrame).toContain("x clear");
});

it("separates foreground subagents from background tasks with section headers", () => {
Expand Down
7 changes: 7 additions & 0 deletions apps/ade-cli/src/tuiClient/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8852,6 +8852,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
if (!conn) return;
const steerActiveTurn = async (): Promise<void> => {
const result = await steerChatMessage(conn, sessionId, text, attachments);
// A full steer queue drops the message server-side. Surface it the same way
// the primary messageSession path does — throw so submitPrompt restores the
// typed text and shows an error — instead of falsely implying it was sent.
if (result.reason === "queue_full") {
throw new Error("The Claude steer queue is full; the message was not queued.");
}
if (result.queued) {
addNotice("Staged message — sends after the current turn.", "info");
}
Expand Down Expand Up @@ -13656,6 +13662,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath,
return;
}
if (chatInfoDisclosureKey === "x") {
if (subagentPaneViewState.earlierExpanded?.[focusedSection] !== true) return;
const clearIds = paneRows
.filter((row): row is Extract<SubagentPaneRow, { kind: "snapshot" }> => (
row.kind === "snapshot" && row.section === focusedSection && row.group === "earlier"
Expand Down
20 changes: 9 additions & 11 deletions apps/ade-cli/src/tuiClient/components/RightPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -862,7 +862,8 @@ function ChatInfoRoster({
: null;
const disclosureHints = selectedHeader ? [
...(selectedHeader.collapsible ? ["c section"] : []),
...(selectedHeader.earlierCount > 0 || selectedHeader.clearedCount > 0 ? ["e earlier"] : []),
...(selectedHeader.earlierCount > 0 || selectedHeader.clearedCount > 0 ? ["e completed"] : []),
...(selectedHeader.hasClear && viewState.earlierExpanded?.[selectedHeader.section] === true ? ["x clear"] : []),
...(paneRows.some((row) => row.kind === "show-all" && row.section === selectedSection) ? ["a all"] : []),
] : [];
const { visibleRows: visibleSlice, hiddenBefore, hiddenAfter } = windowSubagentPaneRows(
Expand Down Expand Up @@ -890,7 +891,7 @@ function ChatInfoRoster({
) : (
<>
{hiddenBefore > 0 ? (
<Text color={theme.color.t4} dimColor>{` ↑ ${hiddenBefore} earlier`}</Text>
<Text color={theme.color.t4} dimColor>{` ↑ ${hiddenBefore} completed`}</Text>
) : null}
{visibleSlice.map((row) => {
if (row.kind === "section-header") {
Expand All @@ -899,7 +900,7 @@ function ChatInfoRoster({
if (row.kind === "earlier-toggle") {
return (
<Text key={row.key} color={theme.color.t4} dimColor>
{` ${row.expanded ? "▾" : "▸"} earlier (${row.count})${row.clearedCount ? ` · ${row.clearedCount} hidden` : ""}`}
{` ${row.expanded ? "▾" : "▸"} completed (${row.count})${row.clearedCount ? ` · ${row.clearedCount} hidden` : ""}`}
</Text>
);
}
Expand Down Expand Up @@ -988,13 +989,10 @@ function rosterFooterHint(
// so the mouse-click line-math stays accurate.
function RosterSectionHead({ row }: { row: Extract<SubagentPaneRow, { kind: "section-header" }> }) {
const color = row.section === "background" ? theme.color.tool : theme.color.t4;
const count = row.earlierCount
? `${row.activeCount} · ${row.earlierCount} earlier`
: `${row.activeCount}`;
return (
<Box marginTop={1}>
<Text color={color} dimColor>
{row.collapsible ? (row.collapsed ? "▸ " : "▾ ") : ""}{row.label.toLowerCase()} {count}{row.clearedCount ? ` · ${row.clearedCount} hidden` : ""}
{row.collapsible ? (row.collapsed ? "▸ " : "▾ ") : ""}{row.label.toLowerCase()} {row.activeCount}{row.clearedCount ? ` · ${row.clearedCount} hidden` : ""}
</Text>
</Box>
);
Expand Down Expand Up @@ -1129,7 +1127,7 @@ function ChatInfoScheduleBlock({ info, brandColor, width, viewState }: { info: C
};
return (
<Box flexDirection="column">
<ChatInfoSectionHead title="SCHEDULE" hint={grouped.earlier.length ? `${grouped.active.length} · ${grouped.earlier.length} earlier` : `${grouped.active.length}`} color={brandColor} width={width} />
<ChatInfoSectionHead title="SCHEDULE" hint={`${grouped.active.length}`} color={brandColor} width={width} />
{nextWake ? (
<Text color={theme.color.t2} wrap="truncate-end">
{` ⏰ next wake ${nextWake}`}
Expand All @@ -1138,7 +1136,7 @@ function ChatInfoScheduleBlock({ info, brandColor, width, viewState }: { info: C
{capped.visible.map((item) => renderItem(item, false))}
{capped.hiddenCount > 0 ? <Text color={theme.color.t4} dimColor>{` + show all (${capped.hiddenCount})`}</Text> : null}
{grouped.earlier.length > 0 || grouped.clearedCount > 0 ? (
<Text color={theme.color.t4} dimColor>{` ${earlierExpanded ? "▾" : "▸"} earlier (${grouped.earlier.length})${grouped.clearedCount ? ` · ${grouped.clearedCount} hidden` : ""}`}</Text>
<Text color={theme.color.t4} dimColor>{` ${earlierExpanded ? "▾" : "▸"} completed (${grouped.earlier.length})${grouped.clearedCount ? ` · ${grouped.clearedCount} hidden` : ""}`}</Text>
) : null}
{earlierExpanded ? grouped.earlier.map((item) => renderItem(item, true)) : null}
{earlierExpanded && grouped.clearedCount > 0 ? <Text color={theme.color.t4} dimColor>{` restore (${grouped.clearedCount})`}</Text> : null}
Expand Down Expand Up @@ -1174,10 +1172,10 @@ function ChatInfoBackgroundBlock({ info, brandColor, width, viewState }: { info:
};
return (
<Box flexDirection="column">
<ChatInfoSectionHead title="BACKGROUND" hint={grouped.earlier.length ? `${grouped.active.length} · ${grouped.earlier.length} earlier` : `${grouped.active.length}`} color={brandColor} width={width} />
<ChatInfoSectionHead title="BACKGROUND" hint={`${grouped.active.length}`} color={brandColor} width={width} />
{capped.visible.map(renderItem)}
{capped.hiddenCount > 0 ? <Text color={theme.color.t4} dimColor>{` + show all (${capped.hiddenCount})`}</Text> : null}
{grouped.earlier.length > 0 || grouped.clearedCount > 0 ? <Text color={theme.color.t4} dimColor>{` ${earlierExpanded ? "▾" : "▸"} earlier (${grouped.earlier.length})`}</Text> : null}
{grouped.earlier.length > 0 || grouped.clearedCount > 0 ? <Text color={theme.color.t4} dimColor>{` ${earlierExpanded ? "▾" : "▸"} completed (${grouped.earlier.length})`}</Text> : null}
{earlierExpanded ? grouped.earlier.map(renderItem) : null}
</Box>
);
Expand Down
227 changes: 223 additions & 4 deletions apps/desktop/src/main/services/chat/agentChatService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8562,7 +8562,10 @@ describe("createAgentChatService", () => {
expect(terminalStatuses.length).toBeGreaterThanOrEqual(1);
});

it("stops still-open background ids at turn end when the notification never arrives", async () => {
it("keeps a still-open background task running across a normal turn boundary (no turn-end stop)", async () => {
// A run_in_background shell keeps running across turns: the SDK query
// stays alive and delivers the real completion on a later turn. Turn end
// must NOT falsely settle it as stopped.
const events: AgentChatEventEnvelope[] = [];
let streamCall = 0;
let warmupComplete = false;
Expand Down Expand Up @@ -8605,17 +8608,233 @@ describe("createAgentChatService", () => {

turnDone!();
await expect(sendPromise).resolves.toBeUndefined();

// The turn-end sweep must settle the orphan as stopped.
// Wait for the turn to actually settle so any (erroneous) turn-end sweep
// would have fired by now.
await waitForEvent(events, (e): e is AgentChatEventEnvelope =>
e.event.type === "done" && (e.event as any).status === "completed");

// The background row must NOT have been settled at the turn boundary.
const terminalBgRows = events.filter((e) =>
e.event.type === "scheduled_work_update"
&& (e.event as any).id === "background:bg-orphan"
&& (e.event as any).status === "stopped");
&& ((e.event as any).status === "stopped" || (e.event as any).status === "completed"));
expect(terminalBgRows).toEqual([]);
// And no subagent_result leaked for the background shell.
expect(events.some((e) =>
e.event.type === "subagent_result" && (e.event as any).taskId === "bg-orphan")).toBe(false);
});

it("settles a still-open background task as stopped on interrupt (genuine teardown)", async () => {
const events: AgentChatEventEnvelope[] = [];
let streamCall = 0;
let warmupComplete = false;
let hangResolve: (() => void) | null = null;
const hangPromise = new Promise<void>((resolve) => { hangResolve = resolve; });
const send = vi.fn().mockResolvedValue(undefined);
const setPermissionMode = vi.fn().mockResolvedValue(undefined);
const stream = vi.fn(() => (async function* () {
streamCall += 1;
if (streamCall === 1) {
yield { type: "system", subtype: "init", session_id: "sdk-bg-int", slash_commands: [] };
warmupComplete = true;
yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } };
return;
}
yield {
type: "system",
subtype: "task_started",
task_id: "bg-int",
description: "long lived background",
command: "tail -f log",
task_type: "background",
};
await hangPromise;
yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } };
})());
vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({
send, stream, close: vi.fn(), sessionId: "sdk-bg-int", setPermissionMode,
} as any);
const { service } = createService({ onEvent: (event: AgentChatEventEnvelope) => events.push(event) });
const session = await service.createSession({ laneId: "lane-1", provider: "claude", model: "sonnet" });
await vi.waitFor(() => { expect(warmupComplete).toBe(true); });
const sendPromise = service.sendMessage({ sessionId: session.id, text: "start bg" });

await waitForEvent(events, (e): e is AgentChatEventEnvelope =>
e.event.type === "scheduled_work_update"
&& (e.event as any).id === "background:bg-int"
&& (e.event as any).status === "running");

await service.interrupt({ sessionId: session.id });

// Interrupt is a genuine teardown — the query is gone, so settle stopped.
await waitForEvent(events, (e): e is AgentChatEventEnvelope =>
e.event.type === "scheduled_work_update"
&& (e.event as any).id === "background:bg-int"
&& (e.event as any).status === "stopped");
// Still never a subagent_result for a background shell.
expect(events.some((e) =>
e.event.type === "subagent_result" && (e.event as any).taskId === "bg-int")).toBe(false);

hangResolve!();
await expect(sendPromise).resolves.toBeUndefined();
});

it("routes a local_bash run_in_background shell to background_task rows, not subagent events, with background flag", async () => {
// The Claude Agent SDK tags Bash run_in_background with task_type
// "local_bash". It must land in the background pane (never the roster) and
// its scheduled_work row must be a background_task.
const events: AgentChatEventEnvelope[] = [];
let streamCall = 0;
let warmupComplete = false;
let turnDone: (() => void) | null = null;
const turnDonePromise = new Promise<void>((resolve) => { turnDone = resolve; });
const send = vi.fn().mockResolvedValue(undefined);
const setPermissionMode = vi.fn().mockResolvedValue(undefined);
const stream = vi.fn(() => (async function* () {
streamCall += 1;
if (streamCall === 1) {
yield { type: "system", subtype: "init", session_id: "sdk-lbash-1", slash_commands: [] };
warmupComplete = true;
yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } };
return;
}
yield {
type: "system",
subtype: "task_started",
task_id: "bgo5i8f6y",
description: "Run codex gpt-5.6-sol backend implementation (background)",
command: "codex exec -m gpt-5.6-sol",
task_type: "local_bash",
};
yield {
type: "system",
subtype: "task_notification",
task_id: "bgo5i8f6y",
status: "completed",
summary: "Process exited",
usage: { duration_ms: 9000 },
};
await turnDonePromise;
yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } };
})());
vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({
send, stream, close: vi.fn(), sessionId: "sdk-lbash-1", setPermissionMode,
} as any);
const { service } = createService({ onEvent: (event: AgentChatEventEnvelope) => events.push(event) });
const session = await service.createSession({ laneId: "lane-1", provider: "claude", model: "sonnet" });
await vi.waitFor(() => { expect(warmupComplete).toBe(true); });
const sendPromise = service.sendMessage({ sessionId: session.id, text: "run codex in background" });

const runningRow = await waitForEvent(events, (e): e is AgentChatEventEnvelope =>
e.event.type === "scheduled_work_update"
&& (e.event as any).id === "background:bgo5i8f6y"
&& (e.event as any).status === "running");
expect((runningRow.event as any).kind).toBe("background_task");
expect((runningRow.event as any).title).toBe("Run codex gpt-5.6-sol backend implementation (background)");

// No subagent_* events for a background shell (this is the background:false
// spawn-flag pollution the classifier now prevents).
const subagentEvents = events.filter((e) =>
(e.event.type === "subagent_started"
|| e.event.type === "subagent_progress"
|| e.event.type === "subagent_result")
&& (e.event as any).taskId === "bgo5i8f6y");
expect(subagentEvents).toEqual([]);

turnDone!();
await expect(sendPromise).resolves.toBeUndefined();
});

it("suppresses subagent rows for a plain Claude Code task run (no agent metadata)", async () => {
// A task run like "Re-run affected test files" carries no agentType /
// agentId and a non-subagent task type — it must never pollute the roster.
const events: AgentChatEventEnvelope[] = [];
let streamCall = 0;
let warmupComplete = false;
let turnDone: (() => void) | null = null;
const turnDonePromise = new Promise<void>((resolve) => { turnDone = resolve; });
const send = vi.fn().mockResolvedValue(undefined);
const setPermissionMode = vi.fn().mockResolvedValue(undefined);
const stream = vi.fn(() => (async function* () {
streamCall += 1;
if (streamCall === 1) {
yield { type: "system", subtype: "init", session_id: "sdk-nonagent-1", slash_commands: [] };
warmupComplete = true;
yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } };
return;
}
yield {
type: "system",
subtype: "task_started",
task_id: "bwguvejv9",
description: "Re-run affected test files",
task_type: "other",
};
yield {
type: "system",
subtype: "task_progress",
task_id: "bwguvejv9",
summary: "running vitest",
};
yield {
type: "system",
subtype: "task_notification",
task_id: "bwguvejv9",
status: "completed",
summary: "3 files passed",
};
await turnDonePromise;
yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } };
})());
vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({
send, stream, close: vi.fn(), sessionId: "sdk-nonagent-1", setPermissionMode,
} as any);
const { service } = createService({ onEvent: (event: AgentChatEventEnvelope) => events.push(event) });
const session = await service.createSession({ laneId: "lane-1", provider: "claude", model: "sonnet" });
await vi.waitFor(() => { expect(warmupComplete).toBe(true); });
const sendPromise = service.sendMessage({ sessionId: session.id, text: "run tests" });

await vi.waitFor(() => {
expect(events.some((e) => e.event.type === "status")).toBe(true);
});

// No subagent_* events AND no background_task row for a plain task run.
const subagentEvents = events.filter((e) =>
(e.event.type === "subagent_started"
|| e.event.type === "subagent_progress"
|| e.event.type === "subagent_result")
&& (e.event as any).taskId === "bwguvejv9");
expect(subagentEvents).toEqual([]);
const bgRows = events.filter((e) =>
e.event.type === "scheduled_work_update" && (e.event as any).id === "background:bwguvejv9");
expect(bgRows).toEqual([]);

turnDone!();
await expect(sendPromise).resolves.toBeUndefined();
});

it("preserves the spawn title on a terminal background row when the hook diff-close omits it", async () => {
// The hook diff-close terminal row carries no title; the sticky per-task
// title must supply the original spawn description instead of a generic
// "Background work" fallback.
const { events, fireSnapshot } = await bootClaudeHooks("sdk-bg-title-1");

await fireSnapshot([{
id: "bg-title",
type: "shell",
status: "running",
description: "Run codex gpt-5.6-sol backend implementation",
}]);
await fireSnapshot([]);

const terminal = events.find((e) =>
e.event.type === "scheduled_work_update"
&& (e.event as any).id === "background:bg-title"
&& ((e.event as any).status === "completed" || (e.event as any).status === "stopped"));
expect(terminal).toBeDefined();
expect((terminal!.event as any).title).toBe("Run codex gpt-5.6-sol backend implementation");
});

it("does not cross-wire finalSummary between two concurrent subagents on an empty task_notification", async () => {
const events: AgentChatEventEnvelope[] = [];
let streamCall = 0;
Expand Down
Loading