From f5024d4e534d63cd0db99c109f68a9acf4b72e1f Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 30 Sep 2026 02:11:11 -0700 Subject: [PATCH 1/2] test(agent-chat): cover per-turn plan scope and plan activity keys Co-Authored-By: Claude Opus 5 --- agent-chat/test/plan-scope.test.ts | 38 ++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 agent-chat/test/plan-scope.test.ts diff --git a/agent-chat/test/plan-scope.test.ts b/agent-chat/test/plan-scope.test.ts new file mode 100644 index 000000000000..8df1fc71279d --- /dev/null +++ b/agent-chat/test/plan-scope.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from "bun:test"; + +Object.defineProperty(globalThis, "location", { + configurable: true, + value: { pathname: "/" }, +}); + +const { activityTailKey } = await import("../src/activity"); +const { foldEvent } = await import("../src/session"); +const { groupTurns } = await import("../src/turns"); + +const firstPlan = { + kind: "plan" as const, + entries: [{ text: "First turn", status: "in_progress" as const }], +}; + +test("keeps plans in the turn where they arrive", () => { + const blocks = foldEvent( + foldEvent(foldEvent([], firstPlan), { kind: "user", text: "next turn" }), + { kind: "plan", entries: [{ text: "Second turn", status: "pending" }] }, + ); + const groups = groupTurns(blocks); + + expect(groups).toHaveLength(2); + expect(groups.map((group) => group.activity.filter((block) => block.kind === "plan").length)).toEqual([1, 1]); + expect(groups[0]?.activity).toContainEqual(firstPlan); + expect(groups[1]?.activity).toContainEqual({ + kind: "plan", + entries: [{ text: "Second turn", status: "pending" }], + }); +}); + +test("refreshes the activity key when a plan entry changes status", () => { + const pending = [{ kind: "plan" as const, entries: [{ text: "long text that is not part of the key", status: "pending" as const }] }]; + const inProgress = [{ kind: "plan" as const, entries: [{ text: "a different long text", status: "in_progress" as const }] }]; + + expect(activityTailKey(pending)).not.toBe(activityTailKey(inProgress)); +}); From 1daa7434362fb3d2a6f45acb8809d761a6b1f228 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 30 Sep 2026 02:13:01 -0700 Subject: [PATCH 2/2] fix(agent-chat): keep a plan in its own turn and refresh the activity key Co-Authored-By: Claude Opus 5 --- agent-chat/adapters/acp.ts | 4 ++-- agent-chat/src/activity.ts | 4 ++++ agent-chat/src/session.ts | 9 ++++++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/agent-chat/adapters/acp.ts b/agent-chat/adapters/acp.ts index 59cd139d6034..e2c5dd4c6d77 100644 --- a/agent-chat/adapters/acp.ts +++ b/agent-chat/adapters/acp.ts @@ -458,7 +458,7 @@ function normalizeAcpPlanStatus(status: unknown): AgentPlanStatus { return status === "pending" || status === "in_progress" || status === "completed" ? status : "unknown"; } -export function normalizeAcpPlanEntries(entries: unknown): AgentPlanEntry[] { +function normalizeAcpPlanEntries(entries: unknown): AgentPlanEntry[] { if (!Array.isArray(entries)) return []; return entries.map((entry: any) => ({ text: typeof entry?.content === "string" ? entry.content : String(entry?.content ?? ""), @@ -467,7 +467,7 @@ export function normalizeAcpPlanEntries(entries: unknown): AgentPlanEntry[] { })); } -export function acpPlanEvent(entries: unknown): Extract | null { +function acpPlanEvent(entries: unknown): Extract | null { const normalized = normalizeAcpPlanEntries(entries); return normalized.length ? { kind: "plan", entries: normalized } : null; } diff --git a/agent-chat/src/activity.ts b/agent-chat/src/activity.ts index e325ffcc95bc..4d963bde5f97 100644 --- a/agent-chat/src/activity.ts +++ b/agent-chat/src/activity.ts @@ -20,6 +20,10 @@ export function activityTailKey(blocks: Block[]): string { const prefix = `${blocks.length}:`; if (tail.kind === "assistant" || tail.kind === "thinking") return `${prefix}${tail.kind}:${tail.open}`; if (tail.kind === "tool") return `${prefix}${tail.kind}:${tail.toolId}:${tail.status}`; + if (tail.kind === "plan") { + const state = tail.entries.map((entry) => [entry.status, entry.priority ?? null]); + return `${prefix}${tail.kind}:${JSON.stringify(state)}`; + } if (tail.kind === "footer" || tail.kind === "status" || tail.kind === "error" || tail.kind === "user") { return `${prefix}${tail.kind}`; } diff --git a/agent-chat/src/session.ts b/agent-chat/src/session.ts index 9a8c008ada2f..48426053b769 100644 --- a/agent-chat/src/session.ts +++ b/agent-chat/src/session.ts @@ -214,7 +214,14 @@ export function foldEvent(blocks: Block[], evt: AgentEvent): Block[] { return [...closeStreaming(blocks), { kind: "status", text: evt.text }]; case "plan": { const closed = closeStreaming(blocks); - const existingIndex = closed.findIndex((block) => block.kind === "plan"); + let currentTurnStart = 0; + for (let index = closed.length - 1; index >= 0; index -= 1) { + if (closed[index]?.kind === "user") { + currentTurnStart = index + 1; + break; + } + } + const existingIndex = closed.findIndex((block, index) => index >= currentTurnStart && block.kind === "plan"); const plan = { kind: "plan" as const, entries: evt.entries }; if (existingIndex < 0) return [...closed, plan]; return closed.reduce((next, block, index) => {