Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
73 changes: 73 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 @@ -1917,6 +1925,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);
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const flushBufferedReasoning = (managed: ManagedChatSession): void => {
const buffered = managed.bufferedReasoning;
if (!buffered) return;
Expand All @@ -1941,6 +1997,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 +2013,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 +2042,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 +2155,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 +2309,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 +4871,7 @@ export function createAgentChatService(args: {
autoTitleStage: "none",
autoTitleInFlight: false,
previewTextBuffer: null,
bufferedText: null,
recentConversationEntries: [],
};

Expand Down Expand Up @@ -5050,6 +5122,7 @@ export function createAgentChatService(args: {
lastActivitySignature: null,
bufferedReasoning: null,
previewTextBuffer: null,
bufferedText: null,
recentConversationEntries: [],
};
managed.transcriptLimitReached = managed.transcriptBytesWritten >= MAX_CHAT_TRANSCRIPT_BYTES;
Expand Down
113 changes: 113 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,113 @@
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("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);
});
});
53 changes: 53 additions & 0 deletions apps/desktop/src/main/services/chat/chatTextBatching.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
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;
return (buffered.turnId ?? null) === (event.turnId ?? null)
&& (buffered.itemId ?? null) === (event.itemId ?? null);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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":
case "todo_update":
case "subagent_started":
case "subagent_progress":
case "subagent_result":
case "tool_use_summary":
case "web_search":
case "auto_approval_review":
return false;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
default:
return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import type { ComponentProps } from "react";
import { createDefaultComputerUsePolicy } from "../../../shared/types";
import { getPermissionOptions } from "../shared/permissionOptions";
import { AgentChatComposer } from "./AgentChatComposer";

function renderComposer(overrides: Partial<ComponentProps<typeof AgentChatComposer>> = {}) {
Expand Down Expand Up @@ -42,6 +43,23 @@ function renderComposer(overrides: Partial<ComponentProps<typeof AgentChatCompos
return props;
}

const executionModeOptions = [
{
value: "focused",
label: "Focused",
summary: "Single stream",
helper: "Keep work in one stream.",
accent: "#38bdf8",
},
{
value: "parallel",
label: "Parallel",
summary: "Split work",
helper: "Use parallel branches for independent tasks.",
accent: "#c084fc",
},
] as NonNullable<ComponentProps<typeof AgentChatComposer>["executionModeOptions"]>;

describe("AgentChatComposer", () => {
it("clear draft only triggers the draft-clear action during an active turn", () => {
const props = renderComposer();
Expand All @@ -61,4 +79,40 @@ describe("AgentChatComposer", () => {
expect(props.onInterrupt).toHaveBeenCalledTimes(1);
expect(props.onClearDraft).not.toHaveBeenCalled();
});

it("labels the Codex plan permission mode as Plan", () => {
expect(getPermissionOptions({ family: "openai", isCliWrapped: true })[0]?.label).toBe("Plan");
});

it("opens the advanced popover and wires the advanced controls", () => {
const onExecutionModeChange = vi.fn();
const onComputerUsePolicyChange = vi.fn();
const onToggleProof = vi.fn();
const onIncludeProjectDocsChange = vi.fn();
renderComposer({
executionMode: "focused",
executionModeOptions,
onExecutionModeChange,
onComputerUsePolicyChange,
onToggleProof,
includeProjectDocs: false,
onIncludeProjectDocsChange,
});

fireEvent.click(screen.getByRole("button", { name: "Advanced" }));

fireEvent.click(screen.getByRole("button", { name: /^Parallel/ }));
fireEvent.click(screen.getByRole("button", { name: /^Computer use (On|Off)$/i }));
fireEvent.click(screen.getByRole("button", { name: /^Proof\b/i }));
fireEvent.click(screen.getByRole("button", { name: /^Project Context\b/i }));

expect(onExecutionModeChange).toHaveBeenCalledWith("parallel");
expect(onComputerUsePolicyChange).toHaveBeenCalledTimes(1);
expect(onComputerUsePolicyChange.mock.calls[0]?.[0]?.mode).toBe("off");
expect(onToggleProof).toHaveBeenCalledTimes(1);
expect(onIncludeProjectDocsChange).toHaveBeenCalledWith(true);

fireEvent.click(screen.getByRole("button", { name: "Advanced" }));
expect(screen.queryByText("Advanced settings")).not.toBeInTheDocument();
});
});
Loading
Loading