Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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