Skip to content

Commit 7fac050

Browse files
arul28claude
andauthored
fix codex chat (#80)
* Implement chat text batching across service and renderer * Fix CI checks and address review feedback for chat text batching - Fix typecheck errors: waitFor import, toBeInTheDocument matcher, executionMode/currentLaneId nullability - Fix test failures: add location.search to LocationProbe, add afterEach(cleanup) to SubagentStrip and Composer tests, fix Computer use button accessible name matcher - Reject directory-traversal (../) and home-relative (~/) paths in workspace path resolution - Flush buffered text before transcript reads so snapshots include pending assistant content - Don't collapse anonymous text chunks lacking turnId/itemId identity - Remove discrete UI card events (todo_update, subagent_started, etc.) from the non-flush allowlist so they properly flush buffered prose - Add aria-pressed to Computer use, Proof, and Project Context toggles - Return focus to Advanced button trigger on Escape dismiss - Add onFocus/onBlur handlers to SubagentStrip task rows for keyboard accessibility - Derive assistantLabel from model descriptor before session provider to avoid stale provider drift Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix transcript rendering tests and address new review feedback - Fix 5 transcript rendering tests: use getAllByText for elements that appear in both summary and detail views, add text event to activity test so streaming indicator renders, use body textContent for diff assertions - Handle Windows drive-letter paths in workspace path resolution by normalizing backslashes and stripping leading slash from /C:/... - Make InlineDisclosureRow react to defaultOpen prop changes so rows auto-expand when status changes (e.g. task failure) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add aria-expanded to InlineDisclosureRow for screen reader support Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix empty-state masking, text back-merge safety, and turn model fallback - Show streaming indicator even when rows is empty (activity-only sessions no longer display the onboarding empty state) - Require turnId or itemId identity before merging text chunks in the renderer, preventing anonymous text from collapsing into unrelated assistant messages - Only resolve turn model from the done-event map; return null for active turns instead of inheriting the previous turn's model Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent dc4ced8 commit 7fac050

12 files changed

Lines changed: 2442 additions & 585 deletions

apps/desktop/src/main/services/chat/agentChatService.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ type ClaudeV2Session = {
2323
readonly sessionId: string;
2424
};
2525
import { buildClaudeV2Message, inferAttachmentMediaType } from "./buildClaudeV2Message";
26+
import {
27+
appendBufferedAssistantText,
28+
canAppendBufferedAssistantText,
29+
shouldFlushBufferedAssistantTextForEvent,
30+
type BufferedAssistantText,
31+
} from "./chatTextBatching";
2632
import type { Logger } from "../logging/logger";
2733
import type { createLaneService } from "../lanes/laneService";
2834
import type { createSessionService } from "../sessions/sessionService";
@@ -262,6 +268,7 @@ type ManagedChatSession = {
262268
turnId?: string;
263269
itemId?: string;
264270
} | null;
271+
bufferedText: (BufferedAssistantText & { timer: NodeJS.Timeout | null }) | null;
265272
recentConversationEntries: Array<{
266273
role: "user" | "assistant";
267274
text: string;
@@ -336,6 +343,7 @@ const DEFAULT_UNIFIED_MODEL_ID = DEFAULT_UNIFIED_DESCRIPTOR?.id ?? "anthropic/cl
336343
const DEFAULT_REASONING_EFFORT = "medium";
337344
const DEFAULT_AUTO_TITLE_MODEL_ID = "anthropic/claude-haiku-4-5-api";
338345
const MAX_CHAT_TRANSCRIPT_BYTES = 8 * 1024 * 1024;
346+
const BUFFERED_TEXT_FLUSH_MS = 100;
339347
const CHAT_TRANSCRIPT_LIMIT_NOTICE = "\n[ADE] chat transcript limit reached (8MB). Further events omitted.\n";
340348
const DEFAULT_TRANSCRIPT_READ_LIMIT = 20;
341349
const MAX_TRANSCRIPT_READ_LIMIT = 100;
@@ -1236,6 +1244,8 @@ export function createAgentChatService(args: {
12361244
const managed = ensureManagedSession(sessionId);
12371245
const normalizedLimit = Math.max(1, Math.min(MAX_TRANSCRIPT_READ_LIMIT, Math.floor(limit)));
12381246
const normalizedMaxChars = Math.max(200, Math.min(MAX_TRANSCRIPT_READ_CHARS, Math.floor(maxChars)));
1247+
// Flush any pending buffered text so the transcript includes all content
1248+
flushBufferedText(managed);
12391249
const transcriptEntries = readTranscriptEntries(managed);
12401250
const fallbackEntries = transcriptEntries.length
12411251
? transcriptEntries
@@ -1917,6 +1927,54 @@ export function createAgentChatService(args: {
19171927
});
19181928
};
19191929

1930+
const flushBufferedText = (managed: ManagedChatSession): void => {
1931+
const buffered = managed.bufferedText;
1932+
if (!buffered) return;
1933+
if (buffered.timer) {
1934+
clearTimeout(buffered.timer);
1935+
}
1936+
managed.bufferedText = null;
1937+
if (!buffered.text.length) return;
1938+
commitChatEvent(managed, {
1939+
type: "text",
1940+
text: buffered.text,
1941+
...(buffered.turnId ? { turnId: buffered.turnId } : {}),
1942+
...(buffered.itemId ? { itemId: buffered.itemId } : {}),
1943+
});
1944+
};
1945+
1946+
const scheduleBufferedTextFlush = (managed: ManagedChatSession): void => {
1947+
const buffered = managed.bufferedText;
1948+
if (!buffered || buffered.timer) return;
1949+
buffered.timer = setTimeout(() => {
1950+
if (managed.bufferedText) {
1951+
managed.bufferedText.timer = null;
1952+
}
1953+
flushBufferedText(managed);
1954+
}, BUFFERED_TEXT_FLUSH_MS);
1955+
};
1956+
1957+
const queueBufferedTextEvent = (
1958+
managed: ManagedChatSession,
1959+
event: Extract<AgentChatEvent, { type: "text" }>,
1960+
): void => {
1961+
if (canAppendBufferedAssistantText(managed.bufferedText, event)) {
1962+
managed.bufferedText = {
1963+
...appendBufferedAssistantText(managed.bufferedText, event),
1964+
timer: managed.bufferedText?.timer ?? null,
1965+
};
1966+
scheduleBufferedTextFlush(managed);
1967+
return;
1968+
}
1969+
1970+
flushBufferedText(managed);
1971+
managed.bufferedText = {
1972+
...appendBufferedAssistantText(null, event),
1973+
timer: null,
1974+
};
1975+
scheduleBufferedTextFlush(managed);
1976+
};
1977+
19201978
const flushBufferedReasoning = (managed: ManagedChatSession): void => {
19211979
const buffered = managed.bufferedReasoning;
19221980
if (!buffered) return;
@@ -1941,6 +1999,11 @@ export function createAgentChatService(args: {
19411999
};
19422000

19432001
const emitChatEvent = (managed: ManagedChatSession, event: AgentChatEvent): void => {
2002+
if (event.type === "text") {
2003+
queueBufferedTextEvent(managed, event);
2004+
return;
2005+
}
2006+
19442007
if (event.type === "reasoning") {
19452008
queueReasoningEvent(managed, event);
19462009
return;
@@ -1952,12 +2015,18 @@ export function createAgentChatService(args: {
19522015
return;
19532016
}
19542017
flushBufferedReasoning(managed);
2018+
if (shouldFlushBufferedAssistantTextForEvent(event)) {
2019+
flushBufferedText(managed);
2020+
}
19552021
managed.lastActivitySignature = signature;
19562022
commitChatEvent(managed, event);
19572023
return;
19582024
}
19592025

19602026
flushBufferedReasoning(managed);
2027+
if (shouldFlushBufferedAssistantTextForEvent(event)) {
2028+
flushBufferedText(managed);
2029+
}
19612030

19622031
if (
19632032
event.type === "user_message"
@@ -1975,6 +2044,7 @@ export function createAgentChatService(args: {
19752044
/** Tear down the active runtime, releasing all resources and cancelling pending approvals. */
19762045
const teardownRuntime = (managed: ManagedChatSession): void => {
19772046
flushBufferedReasoning(managed);
2047+
flushBufferedText(managed);
19782048
if (managed.runtime?.kind === "codex") {
19792049
managed.runtime.suppressExitError = true;
19802050
try { managed.runtime.reader.close(); } catch { /* ignore */ }
@@ -2087,6 +2157,8 @@ export function createAgentChatService(args: {
20872157
if (managed.endedNotified) return;
20882158
managed.endedNotified = true;
20892159
clearSubagentSnapshots(managed.session.id);
2160+
flushBufferedText(managed);
2161+
flushBufferedReasoning(managed);
20902162

20912163
if (options?.summary !== undefined) {
20922164
sessionService.setSummary(managed.session.id, options.summary);
@@ -2239,6 +2311,7 @@ export function createAgentChatService(args: {
22392311
lastActivitySignature: null,
22402312
bufferedReasoning: null,
22412313
previewTextBuffer: null,
2314+
bufferedText: null,
22422315
recentConversationEntries: [],
22432316
};
22442317
managed.transcriptLimitReached = managed.transcriptBytesWritten >= MAX_CHAT_TRANSCRIPT_BYTES;
@@ -4800,6 +4873,7 @@ export function createAgentChatService(args: {
48004873
autoTitleStage: "none",
48014874
autoTitleInFlight: false,
48024875
previewTextBuffer: null,
4876+
bufferedText: null,
48034877
recentConversationEntries: [],
48044878
};
48054879

@@ -5050,6 +5124,7 @@ export function createAgentChatService(args: {
50505124
lastActivitySignature: null,
50515125
bufferedReasoning: null,
50525126
previewTextBuffer: null,
5127+
bufferedText: null,
50535128
recentConversationEntries: [],
50545129
};
50555130
managed.transcriptLimitReached = managed.transcriptBytesWritten >= MAX_CHAT_TRANSCRIPT_BYTES;
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
appendBufferedAssistantText,
4+
canAppendBufferedAssistantText,
5+
shouldFlushBufferedAssistantTextForEvent,
6+
} from "./chatTextBatching";
7+
8+
describe("chatTextBatching", () => {
9+
it("appends adjacent text deltas for the same turn and item", () => {
10+
const buffered = appendBufferedAssistantText(null, {
11+
type: "text",
12+
text: "Hello",
13+
turnId: "turn-1",
14+
itemId: "item-1",
15+
});
16+
17+
expect(canAppendBufferedAssistantText(buffered, {
18+
type: "text",
19+
text: " world",
20+
turnId: "turn-1",
21+
itemId: "item-1",
22+
})).toBe(true);
23+
24+
expect(appendBufferedAssistantText(buffered, {
25+
type: "text",
26+
text: " world",
27+
turnId: "turn-1",
28+
itemId: "item-1",
29+
})).toMatchObject({
30+
text: "Hello world",
31+
turnId: "turn-1",
32+
itemId: "item-1",
33+
});
34+
});
35+
36+
it("stops batching when the text identity changes", () => {
37+
const buffered = appendBufferedAssistantText(null, {
38+
type: "text",
39+
text: "Hello",
40+
turnId: "turn-1",
41+
itemId: "item-1",
42+
});
43+
44+
expect(canAppendBufferedAssistantText(buffered, {
45+
type: "text",
46+
text: "Other",
47+
turnId: "turn-2",
48+
itemId: "item-1",
49+
})).toBe(false);
50+
51+
expect(canAppendBufferedAssistantText(buffered, {
52+
type: "text",
53+
text: "Other",
54+
turnId: "turn-1",
55+
itemId: "item-2",
56+
})).toBe(false);
57+
});
58+
59+
it("flushes buffered text on structural chat events", () => {
60+
expect(shouldFlushBufferedAssistantTextForEvent({
61+
type: "tool_call",
62+
tool: "functions.exec_command",
63+
args: { cmd: "pwd" },
64+
itemId: "tool-1",
65+
turnId: "turn-1",
66+
})).toBe(true);
67+
68+
expect(shouldFlushBufferedAssistantTextForEvent({
69+
type: "command",
70+
command: "pwd",
71+
cwd: "/tmp",
72+
output: "",
73+
itemId: "cmd-1",
74+
turnId: "turn-1",
75+
status: "running",
76+
})).toBe(true);
77+
78+
expect(shouldFlushBufferedAssistantTextForEvent({
79+
type: "approval_request",
80+
itemId: "approval-1",
81+
kind: "command",
82+
description: "Run shell command",
83+
turnId: "turn-1",
84+
})).toBe(true);
85+
86+
expect(shouldFlushBufferedAssistantTextForEvent({
87+
type: "done",
88+
turnId: "turn-1",
89+
status: "completed",
90+
})).toBe(true);
91+
});
92+
93+
it("does not collapse anonymous text chunks that lack identity", () => {
94+
const buffered = appendBufferedAssistantText(null, {
95+
type: "text",
96+
text: "Hello",
97+
});
98+
99+
expect(canAppendBufferedAssistantText(buffered, {
100+
type: "text",
101+
text: " world",
102+
})).toBe(false);
103+
});
104+
105+
it("flushes buffered text on discrete UI card events", () => {
106+
expect(shouldFlushBufferedAssistantTextForEvent({
107+
type: "todo_update",
108+
todos: [],
109+
turnId: "turn-1",
110+
} as any)).toBe(true);
111+
112+
expect(shouldFlushBufferedAssistantTextForEvent({
113+
type: "subagent_started",
114+
taskId: "task-1",
115+
turnId: "turn-1",
116+
} as any)).toBe(true);
117+
118+
expect(shouldFlushBufferedAssistantTextForEvent({
119+
type: "web_search",
120+
query: "test",
121+
turnId: "turn-1",
122+
} as any)).toBe(true);
123+
});
124+
125+
it("keeps buffered text live across lightweight progress events", () => {
126+
expect(shouldFlushBufferedAssistantTextForEvent({
127+
type: "activity",
128+
activity: "thinking",
129+
detail: "Reasoning",
130+
turnId: "turn-1",
131+
})).toBe(false);
132+
133+
expect(shouldFlushBufferedAssistantTextForEvent({
134+
type: "reasoning",
135+
text: "Thinking through it",
136+
turnId: "turn-1",
137+
})).toBe(false);
138+
139+
expect(shouldFlushBufferedAssistantTextForEvent({
140+
type: "plan_text",
141+
text: "- step one",
142+
turnId: "turn-1",
143+
})).toBe(false);
144+
});
145+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { AgentChatEvent } from "../../../shared/types";
2+
3+
export type BufferedAssistantText = {
4+
text: string;
5+
turnId?: string;
6+
itemId?: string;
7+
};
8+
9+
export function canAppendBufferedAssistantText(
10+
buffered: BufferedAssistantText | null,
11+
event: Extract<AgentChatEvent, { type: "text" }>,
12+
): boolean {
13+
if (!buffered) return false;
14+
// Don't collapse anonymous chunks that lack any identity
15+
if (!buffered.turnId && !buffered.itemId && !event.turnId && !event.itemId) return false;
16+
return (buffered.turnId ?? null) === (event.turnId ?? null)
17+
&& (buffered.itemId ?? null) === (event.itemId ?? null);
18+
}
19+
20+
export function appendBufferedAssistantText(
21+
buffered: BufferedAssistantText | null,
22+
event: Extract<AgentChatEvent, { type: "text" }>,
23+
): BufferedAssistantText {
24+
if (canAppendBufferedAssistantText(buffered, event)) {
25+
return {
26+
...buffered!,
27+
text: `${buffered!.text}${event.text}`,
28+
};
29+
}
30+
31+
return {
32+
text: event.text,
33+
...(event.turnId ? { turnId: event.turnId } : {}),
34+
...(event.itemId ? { itemId: event.itemId } : {}),
35+
};
36+
}
37+
38+
export function shouldFlushBufferedAssistantTextForEvent(event: AgentChatEvent): boolean {
39+
switch (event.type) {
40+
case "text":
41+
case "reasoning":
42+
case "activity":
43+
case "plan_text":
44+
return false;
45+
default:
46+
return true;
47+
}
48+
}

0 commit comments

Comments
 (0)