From a8e7a803c974992c089f37144f49dcfe9be24338 Mon Sep 17 00:00:00 2001 From: Jiang Bohan Date: Mon, 9 Feb 2026 15:54:10 +0800 Subject: [PATCH] fix(agent): separate display content from agent user turns --- apps/desktop/electron/ipc/hub.ts | 2 +- src/agent/async-agent.test.ts | 13 +- src/agent/async-agent.ts | 10 +- src/agent/runner.ts | 147 +++++++++++------- .../session/session-manager.display.test.ts | 96 ++++++++++++ src/agent/session/session-manager.ts | 31 +++- src/agent/session/types.ts | 12 +- src/hub/rpc/handlers/get-agent-messages.ts | 2 +- 8 files changed, 244 insertions(+), 69 deletions(-) create mode 100644 src/agent/session/session-manager.display.test.ts diff --git a/apps/desktop/electron/ipc/hub.ts b/apps/desktop/electron/ipc/hub.ts index 1e5446ce..ffde8786 100644 --- a/apps/desktop/electron/ipc/hub.ts +++ b/apps/desktop/electron/ipc/hub.ts @@ -358,7 +358,7 @@ export function registerHubIpcHandlers(): void { try { await agent.ensureInitialized() - const allMessages = agent.loadSessionMessages() + const allMessages = agent.loadSessionMessagesForDisplay() const total = allMessages.length // Must match DEFAULT_MESSAGES_LIMIT from @multica/sdk/actions/rpc const limit = options?.limit ?? 200 diff --git a/src/agent/async-agent.test.ts b/src/agent/async-agent.test.ts index 0e089491..26e7cc9d 100644 --- a/src/agent/async-agent.test.ts +++ b/src/agent/async-agent.test.ts @@ -4,7 +4,11 @@ import { AsyncAgent } from "./async-agent.js"; const subscribeCallbacks: Array<(event: any) => void> = []; const internalRunState = { value: false }; -const runMock = vi.fn(async (_prompt: string) => ({ text: "", thinking: undefined, error: undefined as string | undefined })); +const runMock = vi.fn(async (_prompt: string, _options?: { displayPrompt?: string }) => ({ + text: "", + thinking: undefined, + error: undefined as string | undefined, +})); const runInternalMock = vi.fn(async (_prompt: string) => ({ text: "", thinking: undefined, error: undefined as string | undefined })); const flushSessionMock = vi.fn(async () => {}); const persistAssistantSummaryMock = vi.fn(); @@ -103,8 +107,9 @@ describe("AsyncAgent internal flow", () => { await agent.waitForIdle(); expect(runMock).toHaveBeenCalledTimes(1); - const [message] = runMock.mock.calls[0] ?? []; + const [message, runOptions] = runMock.mock.calls[0] ?? []; expect(message).toMatch(/^\[Wed 2026-01-28 20:30 EST\] recent news$/); + expect(runOptions).toEqual({ displayPrompt: "recent news" }); agent.close(); }); @@ -115,7 +120,9 @@ describe("AsyncAgent internal flow", () => { agent.write("raw heartbeat prompt", { injectTimestamp: false }); await agent.waitForIdle(); - expect(runMock).toHaveBeenCalledWith("raw heartbeat prompt"); + expect(runMock).toHaveBeenCalledWith("raw heartbeat prompt", { + displayPrompt: "raw heartbeat prompt", + }); agent.close(); }); diff --git a/src/agent/async-agent.ts b/src/agent/async-agent.ts index bf8733db..57f26bac 100644 --- a/src/agent/async-agent.ts +++ b/src/agent/async-agent.ts @@ -65,7 +65,7 @@ export class AsyncAgent { this.queue = this.queue .then(async () => { if (this._closed) return; - const result = await this.agent.run(message); + const result = await this.agent.run(message, { displayPrompt: content }); // Flush pending session writes so waitForIdle() callers // can safely read session data from disk. await this.agent.flushSession(); @@ -336,6 +336,14 @@ export class AsyncAgent { return this.agent.loadSessionMessages(options); } + /** + * Load session messages for UI rendering. + * User messages prefer displayContent when present. + */ + loadSessionMessagesForDisplay(options?: { includeInternal?: boolean }): AgentMessage[] { + return this.agent.loadSessionMessagesForDisplay(options); + } + /** * Get current provider and model information. */ diff --git a/src/agent/runner.ts b/src/agent/runner.ts index 00fa4d3c..d2a164d4 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -87,6 +87,7 @@ export class Agent { // Internal run state private _internalRun = false; private _runMutex: Promise = Promise.resolve(); + private currentUserDisplayPrompt: string | undefined; // MulticaEvent subscribers (parallel to PiAgentCore's subscriber list) // Typed as AgentEvent | MulticaEvent to match subscribeAll() callback signature @@ -366,9 +367,12 @@ export class Agent { this.emitMulticaEvent({ type: "agent_error", message }); } - async run(prompt: string): Promise { + async run( + prompt: string, + options?: { displayPrompt?: string }, + ): Promise { // Run-level mutex: prevents concurrent run/runInternal from mis-tagging messages - return this.withRunMutex(() => this._run(prompt)); + return this.withRunMutex(() => this._run(prompt, options)); } /** @@ -408,70 +412,78 @@ export class Agent { } } - private async _run(prompt: string): Promise { + private async _run( + prompt: string, + options?: { displayPrompt?: string }, + ): Promise { await this.ensureInitialized(); this.refreshAuthState(); this.output.state.lastAssistantText = ""; + this.currentUserDisplayPrompt = options?.displayPrompt; - // Early validation: check API key before calling PiAgentCore.prompt(), - // because getApiKey errors thrown inside PiAgentCore's internal async - // context result in UnhandledPromiseRejection instead of propagating. - if (!this.currentApiKey) { - const errorMsg = `No API key configured for provider: ${this.resolvedProvider}. Please configure a provider in Agent Settings.`; - return { text: "", error: errorMsg }; - } - - const canRotate = !this.pinnedProfile && this.profileCandidates.length > 1; - let lastError: unknown; - - // Loop to exhaust all candidate profiles on rotatable errors - while (true) { - try { - await this.agent.prompt(prompt); - break; // success — exit loop - } catch (error) { - lastError = error; - - const reason = classifyError(error); - if (this.currentProfileId && isRotatableError(reason)) { - markAuthProfileFailure(this.currentProfileId, reason); - } - - if (!canRotate || !this.currentProfileId) throw error; - if (!isRotatableError(reason)) throw error; - - if (this.debug) { - this.stderr.write( - `[auth-profile] Profile "${this.currentProfileId}" failed (${reason}), attempting rotation...\n`, - ); - } - - if (!this.advanceAuthProfile()) { - throw lastError; // All profiles exhausted - } - - if (this.debug) { - this.stderr.write( - `[auth-profile] Rotated to profile "${this.currentProfileId}"\n`, - ); - } - - // Reset output for retry - this.output.state.lastAssistantText = ""; - // continue loop with new profile + try { + // Early validation: check API key before calling PiAgentCore.prompt(), + // because getApiKey errors thrown inside PiAgentCore's internal async + // context result in UnhandledPromiseRejection instead of propagating. + if (!this.currentApiKey) { + const errorMsg = `No API key configured for provider: ${this.resolvedProvider}. Please configure a provider in Agent Settings.`; + return { text: "", error: errorMsg }; } - } - // Mark success - if (this.currentProfileId) { - markAuthProfileUsed(this.currentProfileId); - markAuthProfileGood(this.resolvedProvider, this.currentProfileId); - } + const canRotate = !this.pinnedProfile && this.profileCandidates.length > 1; + let lastError: unknown; - const thinking = this.reasoningMode !== "off" - ? this.output.state.lastAssistantThinking || undefined - : undefined; - return { text: this.output.state.lastAssistantText, thinking, error: this.agent.state.error }; + // Loop to exhaust all candidate profiles on rotatable errors + while (true) { + try { + await this.agent.prompt(prompt); + break; // success — exit loop + } catch (error) { + lastError = error; + + const reason = classifyError(error); + if (this.currentProfileId && isRotatableError(reason)) { + markAuthProfileFailure(this.currentProfileId, reason); + } + + if (!canRotate || !this.currentProfileId) throw error; + if (!isRotatableError(reason)) throw error; + + if (this.debug) { + this.stderr.write( + `[auth-profile] Profile "${this.currentProfileId}" failed (${reason}), attempting rotation...\n`, + ); + } + + if (!this.advanceAuthProfile()) { + throw lastError; // All profiles exhausted + } + + if (this.debug) { + this.stderr.write( + `[auth-profile] Rotated to profile "${this.currentProfileId}"\n`, + ); + } + + // Reset output for retry + this.output.state.lastAssistantText = ""; + // continue loop with new profile + } + } + + // Mark success + if (this.currentProfileId) { + markAuthProfileUsed(this.currentProfileId); + markAuthProfileGood(this.resolvedProvider, this.currentProfileId); + } + + const thinking = this.reasoningMode !== "off" + ? this.output.state.lastAssistantThinking || undefined + : undefined; + return { text: this.output.state.lastAssistantText, thinking, error: this.agent.state.error }; + } finally { + this.currentUserDisplayPrompt = undefined; + } } /** @@ -562,7 +574,14 @@ export class Agent { private handleSessionEvent(event: AgentEvent) { if (event.type === "message_end") { const message = event.message as AgentMessage; - this.session.saveMessage(message, this._internalRun ? { internal: true } : undefined); + const saveOptions: { internal?: boolean; displayContent?: AgentMessage["content"] } = {}; + if (this._internalRun) { + saveOptions.internal = true; + } + if (message.role === "user" && this.currentUserDisplayPrompt !== undefined) { + saveOptions.displayContent = this.currentUserDisplayPrompt; + } + this.session.saveMessage(message, Object.keys(saveOptions).length > 0 ? saveOptions : undefined); // Skip compaction during internal runs — internal messages will be // rolled back from memory afterwards, so compacting now would be incorrect. if (message.role === "assistant" && !this._internalRun) { @@ -691,6 +710,14 @@ export class Agent { return this.session.loadMessages(options); } + /** + * Load messages from session storage for UI rendering. + * User messages prefer stored displayContent when present. + */ + loadSessionMessagesForDisplay(options?: { includeInternal?: boolean }): AgentMessage[] { + return this.session.loadMessagesForDisplay(options); + } + /** * Get all skills with their eligibility status. * Returns empty array if skills are disabled. diff --git a/src/agent/session/session-manager.display.test.ts b/src/agent/session/session-manager.display.test.ts new file mode 100644 index 00000000..759c461d --- /dev/null +++ b/src/agent/session/session-manager.display.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { SessionManager } from "./session-manager.js"; +import { readEntries, writeEntries } from "./storage.js"; +import type { SessionEntry } from "./types.js"; + +describe("SessionManager display content view", () => { + const testBaseDir = join(tmpdir(), `multica-session-display-${Date.now()}`); + + beforeEach(() => { + if (existsSync(testBaseDir)) { + rmSync(testBaseDir, { recursive: true }); + } + mkdirSync(testBaseDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testBaseDir)) { + rmSync(testBaseDir, { recursive: true }); + } + }); + + it("uses displayContent for user messages in display view", async () => { + const sessionId = "display-view"; + const session = new SessionManager({ sessionId, baseDir: testBaseDir }); + const entries: SessionEntry[] = [ + { + type: "message", + message: { role: "user", content: "[Mon 2026-02-09 14:37 GMT+8] hi" }, + displayContent: "hi", + timestamp: 1, + }, + { + type: "message", + message: { role: "assistant", content: "hello there" }, + timestamp: 2, + }, + ]; + await writeEntries(sessionId, entries, { baseDir: testBaseDir }); + + const raw = session.loadMessages(); + const display = session.loadMessagesForDisplay(); + + expect(raw[0]?.content).toBe("[Mon 2026-02-09 14:37 GMT+8] hi"); + expect(display[0]?.content).toBe("hi"); + expect(display[1]?.content).toBe("hello there"); + }); + + it("keeps internal filtering behavior in display view", async () => { + const sessionId = "display-internal"; + const session = new SessionManager({ sessionId, baseDir: testBaseDir }); + const entries: SessionEntry[] = [ + { + type: "message", + message: { role: "user", content: "[Mon 2026-02-09 14:37 GMT+8] hidden" }, + displayContent: "hidden", + internal: true, + timestamp: 1, + }, + { + type: "message", + message: { role: "user", content: "[Mon 2026-02-09 14:38 GMT+8] visible" }, + displayContent: "visible", + timestamp: 2, + }, + ]; + await writeEntries(sessionId, entries, { baseDir: testBaseDir }); + + const defaultView = session.loadMessagesForDisplay(); + const includeInternalView = session.loadMessagesForDisplay({ includeInternal: true }); + + expect(defaultView).toHaveLength(1); + expect(defaultView[0]?.content).toBe("visible"); + expect(includeInternalView).toHaveLength(2); + expect(includeInternalView[0]?.content).toBe("hidden"); + }); + + it("persists displayContent on saveMessage", async () => { + const sessionId = "display-save"; + const session = new SessionManager({ sessionId, baseDir: testBaseDir }); + + session.saveMessage( + { role: "user", content: "[Mon 2026-02-09 14:39 GMT+8] save me" }, + { displayContent: "save me" }, + ); + await session.flush(); + + const entries = readEntries(sessionId, { baseDir: testBaseDir }) as Array< + Extract + >; + expect(entries).toHaveLength(1); + expect(entries[0]?.displayContent).toBe("save me"); + }); +}); diff --git a/src/agent/session/session-manager.ts b/src/agent/session/session-manager.ts index 6c3c49ae..2a8e745a 100644 --- a/src/agent/session/session-manager.ts +++ b/src/agent/session/session-manager.ts @@ -168,6 +168,17 @@ export class SessionManager { } loadMessages(options?: { includeInternal?: boolean }): AgentMessage[] { + return this.loadMessagesFromEntries(options, false); + } + + loadMessagesForDisplay(options?: { includeInternal?: boolean }): AgentMessage[] { + return this.loadMessagesFromEntries(options, true); + } + + private loadMessagesFromEntries( + options: { includeInternal?: boolean } | undefined, + preferDisplayContent: boolean, + ): AgentMessage[] { const entries = this.loadEntries(); let messages = entries .filter((entry) => { @@ -175,7 +186,17 @@ export class SessionManager { if (!options?.includeInternal && entry.internal) return false; return true; }) - .map((entry) => (entry as { type: "message"; message: AgentMessage }).message); + .map((entry) => { + const messageEntry = entry as Extract; + if ( + preferDisplayContent + && messageEntry.message.role === "user" + && messageEntry.displayContent !== undefined + ) { + return { ...messageEntry.message, content: messageEntry.displayContent }; + } + return messageEntry.message; + }); messages = sanitizeToolCallInputs(messages); messages = sanitizeToolUseResultPairing(messages); return messages; @@ -207,7 +228,10 @@ export class SessionManager { ); } - saveMessage(message: AgentMessage, options?: { internal?: boolean }) { + saveMessage( + message: AgentMessage, + options?: { internal?: boolean; displayContent?: AgentMessage["content"] }, + ) { void this.enqueue(() => appendEntry( this.sessionId, @@ -216,6 +240,9 @@ export class SessionManager { message, timestamp: Date.now(), ...(options?.internal ? { internal: true } : {}), + ...(options?.displayContent !== undefined + ? { displayContent: options.displayContent } + : {}), }, { baseDir: this.baseDir }, ), diff --git a/src/agent/session/types.ts b/src/agent/session/types.ts index f3478649..dfafa061 100644 --- a/src/agent/session/types.ts +++ b/src/agent/session/types.ts @@ -11,7 +11,17 @@ export type SessionMeta = { }; export type SessionEntry = - | { type: "message"; message: AgentMessage; timestamp: number; internal?: boolean } + | { + type: "message"; + message: AgentMessage; + timestamp: number; + internal?: boolean; + /** + * User-visible content preserved for UI/history rendering. + * When omitted, consumers should fall back to message.content. + */ + displayContent?: AgentMessage["content"]; + } | { type: "meta"; meta: SessionMeta; timestamp: number } | { type: "compaction"; diff --git a/src/hub/rpc/handlers/get-agent-messages.ts b/src/hub/rpc/handlers/get-agent-messages.ts index a7df9cd4..534ce025 100644 --- a/src/hub/rpc/handlers/get-agent-messages.ts +++ b/src/hub/rpc/handlers/get-agent-messages.ts @@ -29,7 +29,7 @@ export function createGetAgentMessagesHandler(): RpcHandler { } const session = new SessionManager({ sessionId: agentId }); - const allMessages = session.loadMessages(); + const allMessages = session.loadMessagesForDisplay(); const total = allMessages.length; // When offset is not provided, return the latest messages