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
75 changes: 75 additions & 0 deletions apps/desktop/src/main/services/chat/agentChatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ type ClaudeV2Session = {
readonly sessionId: string;
};
import { buildClaudeV2Message, inferAttachmentMediaType } from "./buildClaudeV2Message";
import {
appendBufferedAssistantText,
canAppendBufferedAssistantText,
shouldFlushBufferedAssistantTextForEvent,
type BufferedAssistantText,
} from "./chatTextBatching";
import type { Logger } from "../logging/logger";
import type { createLaneService } from "../lanes/laneService";
import type { createSessionService } from "../sessions/sessionService";
Expand Down Expand Up @@ -262,6 +268,7 @@ type ManagedChatSession = {
turnId?: string;
itemId?: string;
} | null;
bufferedText: (BufferedAssistantText & { timer: NodeJS.Timeout | null }) | null;
recentConversationEntries: Array<{
role: "user" | "assistant";
text: string;
Expand Down Expand Up @@ -336,6 +343,7 @@ const DEFAULT_UNIFIED_MODEL_ID = DEFAULT_UNIFIED_DESCRIPTOR?.id ?? "anthropic/cl
const DEFAULT_REASONING_EFFORT = "medium";
const DEFAULT_AUTO_TITLE_MODEL_ID = "anthropic/claude-haiku-4-5-api";
const MAX_CHAT_TRANSCRIPT_BYTES = 8 * 1024 * 1024;
const BUFFERED_TEXT_FLUSH_MS = 100;
const CHAT_TRANSCRIPT_LIMIT_NOTICE = "\n[ADE] chat transcript limit reached (8MB). Further events omitted.\n";
const DEFAULT_TRANSCRIPT_READ_LIMIT = 20;
const MAX_TRANSCRIPT_READ_LIMIT = 100;
Expand Down Expand Up @@ -1236,6 +1244,8 @@ export function createAgentChatService(args: {
const managed = ensureManagedSession(sessionId);
const normalizedLimit = Math.max(1, Math.min(MAX_TRANSCRIPT_READ_LIMIT, Math.floor(limit)));
const normalizedMaxChars = Math.max(200, Math.min(MAX_TRANSCRIPT_READ_CHARS, Math.floor(maxChars)));
// Flush any pending buffered text so the transcript includes all content
flushBufferedText(managed);
const transcriptEntries = readTranscriptEntries(managed);
const fallbackEntries = transcriptEntries.length
? transcriptEntries
Expand Down Expand Up @@ -1917,6 +1927,54 @@ export function createAgentChatService(args: {
});
};

const flushBufferedText = (managed: ManagedChatSession): void => {
const buffered = managed.bufferedText;
if (!buffered) return;
if (buffered.timer) {
clearTimeout(buffered.timer);
}
managed.bufferedText = null;
if (!buffered.text.length) return;
commitChatEvent(managed, {
type: "text",
text: buffered.text,
...(buffered.turnId ? { turnId: buffered.turnId } : {}),
...(buffered.itemId ? { itemId: buffered.itemId } : {}),
});
};

const scheduleBufferedTextFlush = (managed: ManagedChatSession): void => {
const buffered = managed.bufferedText;
if (!buffered || buffered.timer) return;
buffered.timer = setTimeout(() => {
if (managed.bufferedText) {
managed.bufferedText.timer = null;
}
flushBufferedText(managed);
}, BUFFERED_TEXT_FLUSH_MS);
};

const queueBufferedTextEvent = (
managed: ManagedChatSession,
event: Extract<AgentChatEvent, { type: "text" }>,
): void => {
if (canAppendBufferedAssistantText(managed.bufferedText, event)) {
managed.bufferedText = {
...appendBufferedAssistantText(managed.bufferedText, event),
timer: managed.bufferedText?.timer ?? null,
};
scheduleBufferedTextFlush(managed);
return;
}

flushBufferedText(managed);
managed.bufferedText = {
...appendBufferedAssistantText(null, event),
timer: null,
};
scheduleBufferedTextFlush(managed);
};

const flushBufferedReasoning = (managed: ManagedChatSession): void => {
const buffered = managed.bufferedReasoning;
if (!buffered) return;
Expand All @@ -1941,6 +1999,11 @@ export function createAgentChatService(args: {
};

const emitChatEvent = (managed: ManagedChatSession, event: AgentChatEvent): void => {
if (event.type === "text") {
queueBufferedTextEvent(managed, event);
return;
}

if (event.type === "reasoning") {
queueReasoningEvent(managed, event);
return;
Expand All @@ -1952,12 +2015,18 @@ export function createAgentChatService(args: {
return;
}
flushBufferedReasoning(managed);
if (shouldFlushBufferedAssistantTextForEvent(event)) {
flushBufferedText(managed);
}
managed.lastActivitySignature = signature;
commitChatEvent(managed, event);
return;
}

flushBufferedReasoning(managed);
if (shouldFlushBufferedAssistantTextForEvent(event)) {
flushBufferedText(managed);
}

if (
event.type === "user_message"
Expand All @@ -1975,6 +2044,7 @@ export function createAgentChatService(args: {
/** Tear down the active runtime, releasing all resources and cancelling pending approvals. */
const teardownRuntime = (managed: ManagedChatSession): void => {
flushBufferedReasoning(managed);
flushBufferedText(managed);
if (managed.runtime?.kind === "codex") {
managed.runtime.suppressExitError = true;
try { managed.runtime.reader.close(); } catch { /* ignore */ }
Expand Down Expand Up @@ -2087,6 +2157,8 @@ export function createAgentChatService(args: {
if (managed.endedNotified) return;
managed.endedNotified = true;
clearSubagentSnapshots(managed.session.id);
flushBufferedText(managed);
flushBufferedReasoning(managed);

if (options?.summary !== undefined) {
sessionService.setSummary(managed.session.id, options.summary);
Expand Down Expand Up @@ -2239,6 +2311,7 @@ export function createAgentChatService(args: {
lastActivitySignature: null,
bufferedReasoning: null,
previewTextBuffer: null,
bufferedText: null,
recentConversationEntries: [],
};
managed.transcriptLimitReached = managed.transcriptBytesWritten >= MAX_CHAT_TRANSCRIPT_BYTES;
Expand Down Expand Up @@ -4800,6 +4873,7 @@ export function createAgentChatService(args: {
autoTitleStage: "none",
autoTitleInFlight: false,
previewTextBuffer: null,
bufferedText: null,
recentConversationEntries: [],
};

Expand Down Expand Up @@ -5050,6 +5124,7 @@ export function createAgentChatService(args: {
lastActivitySignature: null,
bufferedReasoning: null,
previewTextBuffer: null,
bufferedText: null,
recentConversationEntries: [],
};
managed.transcriptLimitReached = managed.transcriptBytesWritten >= MAX_CHAT_TRANSCRIPT_BYTES;
Expand Down
145 changes: 145 additions & 0 deletions apps/desktop/src/main/services/chat/chatTextBatching.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { describe, expect, it } from "vitest";
import {
appendBufferedAssistantText,
canAppendBufferedAssistantText,
shouldFlushBufferedAssistantTextForEvent,
} from "./chatTextBatching";

describe("chatTextBatching", () => {
it("appends adjacent text deltas for the same turn and item", () => {
const buffered = appendBufferedAssistantText(null, {
type: "text",
text: "Hello",
turnId: "turn-1",
itemId: "item-1",
});

expect(canAppendBufferedAssistantText(buffered, {
type: "text",
text: " world",
turnId: "turn-1",
itemId: "item-1",
})).toBe(true);

expect(appendBufferedAssistantText(buffered, {
type: "text",
text: " world",
turnId: "turn-1",
itemId: "item-1",
})).toMatchObject({
text: "Hello world",
turnId: "turn-1",
itemId: "item-1",
});
});

it("stops batching when the text identity changes", () => {
const buffered = appendBufferedAssistantText(null, {
type: "text",
text: "Hello",
turnId: "turn-1",
itemId: "item-1",
});

expect(canAppendBufferedAssistantText(buffered, {
type: "text",
text: "Other",
turnId: "turn-2",
itemId: "item-1",
})).toBe(false);

expect(canAppendBufferedAssistantText(buffered, {
type: "text",
text: "Other",
turnId: "turn-1",
itemId: "item-2",
})).toBe(false);
});

it("flushes buffered text on structural chat events", () => {
expect(shouldFlushBufferedAssistantTextForEvent({
type: "tool_call",
tool: "functions.exec_command",
args: { cmd: "pwd" },
itemId: "tool-1",
turnId: "turn-1",
})).toBe(true);

expect(shouldFlushBufferedAssistantTextForEvent({
type: "command",
command: "pwd",
cwd: "/tmp",
output: "",
itemId: "cmd-1",
turnId: "turn-1",
status: "running",
})).toBe(true);

expect(shouldFlushBufferedAssistantTextForEvent({
type: "approval_request",
itemId: "approval-1",
kind: "command",
description: "Run shell command",
turnId: "turn-1",
})).toBe(true);

expect(shouldFlushBufferedAssistantTextForEvent({
type: "done",
turnId: "turn-1",
status: "completed",
})).toBe(true);
});

it("does not collapse anonymous text chunks that lack identity", () => {
const buffered = appendBufferedAssistantText(null, {
type: "text",
text: "Hello",
});

expect(canAppendBufferedAssistantText(buffered, {
type: "text",
text: " world",
})).toBe(false);
});

it("flushes buffered text on discrete UI card events", () => {
expect(shouldFlushBufferedAssistantTextForEvent({
type: "todo_update",
todos: [],
turnId: "turn-1",
} as any)).toBe(true);

expect(shouldFlushBufferedAssistantTextForEvent({
type: "subagent_started",
taskId: "task-1",
turnId: "turn-1",
} as any)).toBe(true);

expect(shouldFlushBufferedAssistantTextForEvent({
type: "web_search",
query: "test",
turnId: "turn-1",
} as any)).toBe(true);
});

it("keeps buffered text live across lightweight progress events", () => {
expect(shouldFlushBufferedAssistantTextForEvent({
type: "activity",
activity: "thinking",
detail: "Reasoning",
turnId: "turn-1",
})).toBe(false);

expect(shouldFlushBufferedAssistantTextForEvent({
type: "reasoning",
text: "Thinking through it",
turnId: "turn-1",
})).toBe(false);

expect(shouldFlushBufferedAssistantTextForEvent({
type: "plan_text",
text: "- step one",
turnId: "turn-1",
})).toBe(false);
});
});
48 changes: 48 additions & 0 deletions apps/desktop/src/main/services/chat/chatTextBatching.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { AgentChatEvent } from "../../../shared/types";

export type BufferedAssistantText = {
text: string;
turnId?: string;
itemId?: string;
};

export function canAppendBufferedAssistantText(
buffered: BufferedAssistantText | null,
event: Extract<AgentChatEvent, { type: "text" }>,
): boolean {
if (!buffered) return false;
// Don't collapse anonymous chunks that lack any identity
if (!buffered.turnId && !buffered.itemId && !event.turnId && !event.itemId) return false;
return (buffered.turnId ?? null) === (event.turnId ?? null)
&& (buffered.itemId ?? null) === (event.itemId ?? null);
}

export function appendBufferedAssistantText(
buffered: BufferedAssistantText | null,
event: Extract<AgentChatEvent, { type: "text" }>,
): BufferedAssistantText {
if (canAppendBufferedAssistantText(buffered, event)) {
return {
...buffered!,
text: `${buffered!.text}${event.text}`,
};
}

return {
text: event.text,
...(event.turnId ? { turnId: event.turnId } : {}),
...(event.itemId ? { itemId: event.itemId } : {}),
};
}

export function shouldFlushBufferedAssistantTextForEvent(event: AgentChatEvent): boolean {
switch (event.type) {
case "text":
case "reasoning":
case "activity":
case "plan_text":
return false;
default:
return true;
}
}
Loading
Loading