From 6e5235ac52368649aafa8de2741793bfcfc48271 Mon Sep 17 00:00:00 2001 From: Nick Pape <5674316+nick-pape@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:09:13 -0500 Subject: [PATCH 1/2] Preserve live tool history --- .../src/agent-prompt-cancellation.test.ts | 92 ++++++++++ packages/agent-core/src/agent.ts | 171 +++++++++++++++++- 2 files changed, 261 insertions(+), 2 deletions(-) diff --git a/packages/acp-agent/src/agent-prompt-cancellation.test.ts b/packages/acp-agent/src/agent-prompt-cancellation.test.ts index 6e7b53a..85b81f9 100644 --- a/packages/acp-agent/src/agent-prompt-cancellation.test.ts +++ b/packages/acp-agent/src/agent-prompt-cancellation.test.ts @@ -275,6 +275,98 @@ describe("FledglingAgent prompt cancellation", () => { ); }); + it("includes prior successful tool calls and results in later model turns", async () => { + const { agent, sessionId, streamText } = await createTestAgent(); + const capturedMessages: unknown[] = []; + streamText.mockImplementation((request: { readonly messages: unknown }) => { + capturedMessages.push(JSON.parse(JSON.stringify(request.messages))); + return capturedMessages.length === 1 + ? createImmediateStream([ + { type: "tool-call", toolCallId: "call-1", toolName: "workspace_read", input: { path: "README.md" } }, + { type: "tool-call", toolCallId: "call-2", toolName: "workspace_list", input: { path: "." } }, + { type: "tool-result", toolCallId: "call-1", output: { content: "read ok" } }, + { type: "tool-result", toolCallId: "call-2", output: { entries: ["README.md"] } }, + { type: "text-delta", text: "done" } + ]) + : createImmediateStream([]); + }); + + await agent.prompt({ sessionId, prompt: [{ type: "text", text: "inspect" }] }); + await agent.prompt({ sessionId, prompt: [{ type: "text", text: "continue" }] }); + + expect(capturedMessages[1]).toEqual([ + { role: "user", content: "inspect" }, + { + role: "assistant", + content: [ + { type: "tool-call", toolCallId: "call-1", toolName: "workspace_read", input: { path: "README.md" } }, + { type: "tool-call", toolCallId: "call-2", toolName: "workspace_list", input: { path: "." } } + ] + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-1", + toolName: "workspace_read", + output: { type: "json", value: { content: "read ok" } } + } + ] + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-2", + toolName: "workspace_list", + output: { type: "json", value: { entries: ["README.md"] } } + } + ] + }, + { role: "assistant", content: "done" }, + { role: "user", content: "continue" } + ]); + }); + + it("includes prior failed tool results in later model turns", async () => { + const { agent, sessionId, streamText } = await createTestAgent(); + const capturedMessages: unknown[] = []; + streamText.mockImplementation((request: { readonly messages: unknown }) => { + capturedMessages.push(JSON.parse(JSON.stringify(request.messages))); + return capturedMessages.length === 1 + ? createImmediateStream([ + { type: "tool-call", toolCallId: "call-1", toolName: "workspace_write", input: { path: "x" } }, + { type: "tool-error", toolCallId: "call-1", error: new Error("write failed") } + ]) + : createImmediateStream([]); + }); + + await agent.prompt({ sessionId, prompt: [{ type: "text", text: "write" }] }); + await agent.prompt({ sessionId, prompt: [{ type: "text", text: "what failed" }] }); + + expect(capturedMessages[1]).toEqual([ + { role: "user", content: "write" }, + { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "call-1", toolName: "workspace_write", input: { path: "x" } }] + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-1", + toolName: "workspace_write", + output: { type: "error-text", value: "write failed" } + } + ] + }, + { role: "user", content: "what failed" } + ]); + }); + it("does not use ACP host filesystem or permission methods during prompt flow", async () => { const { manager: sessionManager, sessionFile, tempDir: createdTempDir } = await createTempSessionManager(); const sessionUpdates: FakeSessionUpdate[] = []; diff --git a/packages/agent-core/src/agent.ts b/packages/agent-core/src/agent.ts index 610570c..cf9e4dc 100644 --- a/packages/agent-core/src/agent.ts +++ b/packages/agent-core/src/agent.ts @@ -1,7 +1,7 @@ import * as acp from "@agentclientprotocol/sdk"; import { buildContext } from "@fledgling/context-builder"; import type { SessionEvent } from "@fledgling/common"; -import type { CoreMessage, ToolSet } from "ai"; +import type { CoreMessage, JSONValue, ToolSet } from "ai"; import type { FledglingAgentDependencies, IClosable } from "./interfaces.js"; import { @@ -21,6 +21,33 @@ import { } from "./prompt-content.js"; import { SessionCleanup } from "./session-cleanup.js"; +type AssistantHistoryPart = + | { + readonly type: "text"; + readonly text: string; + } + | { + readonly type: "tool-call"; + readonly toolCallId: string; + readonly toolName: string; + readonly input: unknown; + }; + +type ToolResultOutput = + | { + readonly type: "text" | "error-text"; + readonly value: string; + } + | { + readonly type: "json" | "error-json"; + readonly value: JSONValue; + }; + +interface ToolHistoryBuilder { + pendingAssistantText: string; + readonly pendingAssistantParts: AssistantHistoryPart[]; +} + interface SessionState { readonly id: string; readonly cwd: string | undefined; @@ -238,6 +265,10 @@ export class FledglingAgent implements acp.Agent { session.pendingPrompt = promptController; let assistantText = ""; + const toolHistory: ToolHistoryBuilder = { + pendingAssistantText: "", + pendingAssistantParts: [] + }; try { session.history.push({ role: "user", content: userPrompt.modelContent }); @@ -274,6 +305,7 @@ export class FledglingAgent implements acp.Agent { switch (part.type) { case "text-delta": { assistantText += part.text; + toolHistory.pendingAssistantText += part.text; await this.#connection.sessionUpdate({ sessionId: params.sessionId, update: { @@ -289,6 +321,7 @@ export class FledglingAgent implements acp.Agent { case "tool-call": { session.toolCallNames.set(part.toolCallId, part.toolName); + appendAssistantToolCall(toolHistory, part); await this.#dependencies.sessionManager.appendEvent({ ...this.#dependencies.sessionManager.createEventBase(session.id), type: "tool.call", @@ -312,6 +345,13 @@ export class FledglingAgent implements acp.Agent { } case "tool-result": { + flushAssistantToolHistory(session, toolHistory); + appendToolResultHistory(session, { + toolCallId: part.toolCallId, + toolName: session.toolCallNames.get(part.toolCallId), + output: part.output, + status: "completed" + }); await this.#dependencies.sessionManager.appendEvent({ ...this.#dependencies.sessionManager.createEventBase(session.id), type: "tool.result", @@ -344,6 +384,13 @@ export class FledglingAgent implements acp.Agent { } case "tool-error": { + flushAssistantToolHistory(session, toolHistory); + appendToolResultHistory(session, { + toolCallId: part.toolCallId, + toolName: session.toolCallNames.get(part.toolCallId), + output: part.error, + status: "failed" + }); await this.#dependencies.sessionManager.appendEvent({ ...this.#dependencies.sessionManager.createEventBase(session.id), type: "tool.result", @@ -395,6 +442,8 @@ export class FledglingAgent implements acp.Agent { throw createPromptRpcError(error, "model_stream"); } + flushAssistantToolHistory(session, toolHistory); + appendAssistantTextHistory(session, toolHistory); await this.#persistAssistantMessage(session, assistantText); return { @@ -414,7 +463,6 @@ export class FledglingAgent implements acp.Agent { } async #persistAssistantMessage(session: SessionState, assistantText: string): Promise { - session.history.push({ role: "assistant", content: assistantText }); await this.#dependencies.sessionManager.appendEvent({ ...this.#dependencies.sessionManager.createEventBase(session.id), type: "message.assistant", @@ -467,6 +515,125 @@ export class FledglingAgent implements acp.Agent { } } +function appendAssistantToolCall( + toolHistory: ToolHistoryBuilder, + part: { + readonly toolCallId: string; + readonly toolName: string; + readonly input: unknown; + } +): void { + if (toolHistory.pendingAssistantText) { + toolHistory.pendingAssistantParts.push({ type: "text", text: toolHistory.pendingAssistantText }); + toolHistory.pendingAssistantText = ""; + } + + toolHistory.pendingAssistantParts.push({ + type: "tool-call", + toolCallId: part.toolCallId, + toolName: part.toolName, + input: part.input + }); +} + +function flushAssistantToolHistory(session: SessionState, toolHistory: ToolHistoryBuilder): void { + if (toolHistory.pendingAssistantParts.length === 0) { + return; + } + + if (toolHistory.pendingAssistantText) { + toolHistory.pendingAssistantParts.push({ type: "text", text: toolHistory.pendingAssistantText }); + toolHistory.pendingAssistantText = ""; + } + + session.history.push({ + role: "assistant", + content: [...toolHistory.pendingAssistantParts] + } as CoreMessage); + toolHistory.pendingAssistantParts.length = 0; +} + +function appendAssistantTextHistory(session: SessionState, toolHistory: ToolHistoryBuilder): void { + if (!toolHistory.pendingAssistantText) { + return; + } + + session.history.push({ role: "assistant", content: toolHistory.pendingAssistantText }); + toolHistory.pendingAssistantText = ""; +} + +function appendToolResultHistory( + session: SessionState, + result: { + readonly toolCallId: string; + readonly toolName: string | undefined; + readonly output: unknown; + readonly status: "completed" | "failed"; + } +): void { + session.history.push({ + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: result.toolCallId, + toolName: result.toolName ?? "unknown_tool", + output: toToolResultOutput(result.output, result.status) + } + ] + } as CoreMessage); +} + +function toToolResultOutput(output: unknown, status: "completed" | "failed"): ToolResultOutput { + if (status === "failed") { + return { + type: "error-text", + value: stringifyModelToolOutput(output) + }; + } + + const jsonValue = toJsonValue(output); + if (jsonValue !== undefined) { + return { + type: "json", + value: jsonValue + }; + } + + return { + type: "text", + value: stringifyModelToolOutput(output) + }; +} + +function stringifyModelToolOutput(output: unknown): string { + if (typeof output === "string") { + return output; + } + + if (output instanceof Error) { + return output.message; + } + + if (output === undefined || typeof output === "function" || typeof output === "symbol") { + return String(output); + } + + return JSON.stringify(output, null, 2); +} + +function toJsonValue(value: unknown): JSONValue | undefined { + if (value === undefined || typeof value === "function" || typeof value === "symbol") { + return undefined; + } + + try { + return JSON.parse(JSON.stringify(value)) as JSONValue; + } catch { + return undefined; + } +} + async function replaySessionHistory(connection: acp.AgentSideConnection, session: SessionState): Promise { for (const message of session.history) { if (message.role !== "user" && message.role !== "assistant") { From dc961929582bcb3191e702fb5c5bda66a3f868fe Mon Sep 17 00:00:00 2001 From: Nick Pape <5674316+nick-pape@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:29:16 -0500 Subject: [PATCH 2/2] Address live tool history review feedback --- .../src/agent-prompt-cancellation.test.ts | 75 +++++++++++++++++++ packages/agent-core/src/agent.ts | 54 +++++++++++-- packages/agent-core/src/prompt-content.ts | 6 +- 3 files changed, 126 insertions(+), 9 deletions(-) diff --git a/packages/acp-agent/src/agent-prompt-cancellation.test.ts b/packages/acp-agent/src/agent-prompt-cancellation.test.ts index 85b81f9..536aac8 100644 --- a/packages/acp-agent/src/agent-prompt-cancellation.test.ts +++ b/packages/acp-agent/src/agent-prompt-cancellation.test.ts @@ -550,6 +550,81 @@ describe("FledglingAgent prompt cancellation", () => { ]); }); + it("keeps streamed tool history available after a durable stream error", async () => { + const { agent, sessionId, streamText } = await createTestAgent(); + const capturedMessages: unknown[] = []; + streamText.mockImplementation((request: { readonly messages: unknown }) => { + capturedMessages.push(JSON.parse(JSON.stringify(request.messages))); + return capturedMessages.length === 1 + ? createFailingStream( + [ + { type: "tool-call", toolCallId: "call-1", toolName: "workspace_read", input: { path: "README.md" } }, + { type: "tool-result", toolCallId: "call-1", output: { content: "partial context" } }, + { type: "text-delta", text: "partial" } + ], + new Error("boom") + ) + : createImmediateStream([]); + }); + + await expect(agent.prompt({ sessionId, prompt: [{ type: "text", text: "inspect" }] })).rejects.toThrow( + "Fledgling model stream failed: boom" + ); + await agent.prompt({ sessionId, prompt: [{ type: "text", text: "continue" }] }); + + expect(capturedMessages[1]).toEqual([ + { role: "user", content: "inspect" }, + { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "call-1", toolName: "workspace_read", input: { path: "README.md" } }] + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-1", + toolName: "workspace_read", + output: { type: "json", value: { content: "partial context" } } + } + ] + }, + { role: "assistant", content: "partial" }, + { role: "user", content: "continue" } + ]); + }); + + it("falls back to text when tool output is not JSON-safe", async () => { + const { agent, sessionId, streamText } = await createTestAgent(); + const capturedMessages: unknown[] = []; + const circular: { self?: unknown } = {}; + circular.self = circular; + streamText.mockImplementation((request: { readonly messages: unknown }) => { + capturedMessages.push(JSON.parse(JSON.stringify(request.messages))); + return capturedMessages.length === 1 + ? createImmediateStream([ + { type: "tool-call", toolCallId: "call-1", toolName: "workspace_read", input: { path: "README.md" } }, + { type: "tool-result", toolCallId: "call-1", output: circular } + ]) + : createImmediateStream([]); + }); + + await agent.prompt({ sessionId, prompt: [{ type: "text", text: "inspect" }] }); + await agent.prompt({ sessionId, prompt: [{ type: "text", text: "continue" }] }); + + expect(capturedMessages[1]).toContainEqual({ + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-1", + toolName: "workspace_read", + output: { type: "json", value: "[object Object]" } + } + ] + }); + }); + it("does not persist an empty assistant message when streams fail before text", async () => { const { agent, sessionId, streamText, sessionFile, sessionUpdates } = await createTestAgent(); streamText.mockReturnValueOnce(createFailingStream([], new Error("stream failed"))); diff --git a/packages/agent-core/src/agent.ts b/packages/agent-core/src/agent.ts index cf9e4dc..aceb115 100644 --- a/packages/agent-core/src/agent.ts +++ b/packages/agent-core/src/agent.ts @@ -345,11 +345,12 @@ export class FledglingAgent implements acp.Agent { } case "tool-result": { + const safeOutput = toSafeRawValue(part.output); flushAssistantToolHistory(session, toolHistory); appendToolResultHistory(session, { toolCallId: part.toolCallId, toolName: session.toolCallNames.get(part.toolCallId), - output: part.output, + output: safeOutput, status: "completed" }); await this.#dependencies.sessionManager.appendEvent({ @@ -359,7 +360,7 @@ export class FledglingAgent implements acp.Agent { toolName: session.toolCallNames.get(part.toolCallId), status: "completed", text: stringifyToolOutput(part.output), - rawOutput: part.output, + rawOutput: safeOutput, contextHint: extractContextHint(part.output) }); await this.#connection.sessionUpdate({ @@ -377,18 +378,19 @@ export class FledglingAgent implements acp.Agent { } } ], - rawOutput: toRawObject(part.output) + rawOutput: toRawObject(safeOutput) } }); break; } case "tool-error": { + const safeError = toSafeRawValue(part.error); flushAssistantToolHistory(session, toolHistory); appendToolResultHistory(session, { toolCallId: part.toolCallId, toolName: session.toolCallNames.get(part.toolCallId), - output: part.error, + output: safeError, status: "failed" }); await this.#dependencies.sessionManager.appendEvent({ @@ -398,7 +400,7 @@ export class FledglingAgent implements acp.Agent { toolName: session.toolCallNames.get(part.toolCallId), status: "failed", text: stringifyToolOutput(part.error), - rawOutput: toRawObject(part.error), + rawOutput: toRawObject(safeError), contextHint: undefined }); await this.#connection.sessionUpdate({ @@ -416,7 +418,7 @@ export class FledglingAgent implements acp.Agent { } } ], - rawOutput: toRawObject(part.error) + rawOutput: toRawObject(safeError) } }); break; @@ -429,6 +431,8 @@ export class FledglingAgent implements acp.Agent { } const assistantTextPersisted = assistantText.length > 0; + flushAssistantToolHistory(session, toolHistory); + appendAssistantTextHistory(session, toolHistory); if (assistantTextPersisted) { await this.#persistAssistantMessage(session, assistantText); } @@ -619,7 +623,11 @@ function stringifyModelToolOutput(output: unknown): string { return String(output); } - return JSON.stringify(output, null, 2); + try { + return JSON.stringify(output, null, 2); + } catch { + return String(output); + } } function toJsonValue(value: unknown): JSONValue | undefined { @@ -634,13 +642,26 @@ function toJsonValue(value: unknown): JSONValue | undefined { } } +function toSafeRawValue(value: unknown): unknown { + if (value instanceof Error) { + return toRawObject(value); + } + + try { + JSON.stringify(value); + return value; + } catch { + return stringifyModelToolOutput(value); + } +} + async function replaySessionHistory(connection: acp.AgentSideConnection, session: SessionState): Promise { for (const message of session.history) { if (message.role !== "user" && message.role !== "assistant") { continue; } - const text = messageContentToText(message.content); + const text = message.role === "assistant" ? assistantContentToReplayText(message.content) : messageContentToText(message.content); if (!text) { continue; } @@ -658,6 +679,23 @@ async function replaySessionHistory(connection: acp.AgentSideConnection, session } } +function assistantContentToReplayText(content: CoreMessage["content"]): string { + if (!Array.isArray(content)) { + return messageContentToText(content); + } + + return content + .map((part) => { + if (typeof part === "object" && "type" in part && part.type === "text" && typeof part.text === "string") { + return part.text; + } + + return ""; + }) + .filter((text) => text.length > 0) + .join("\n"); +} + function createSessionModeState(modeId: FledglingSessionModeId): acp.SessionModeState { return { currentModeId: modeId, diff --git a/packages/agent-core/src/prompt-content.ts b/packages/agent-core/src/prompt-content.ts index 6ba80cf..ac51078 100644 --- a/packages/agent-core/src/prompt-content.ts +++ b/packages/agent-core/src/prompt-content.ts @@ -93,7 +93,11 @@ export function stringifyToolOutput(output: unknown): string { return output; } - return JSON.stringify(output, null, 2); + try { + return JSON.stringify(output, null, 2); + } catch { + return String(output); + } } /** Reads a Fledgling context hint from a tool output payload, when present. */