fix(agent): separate display content from agent user turns

This commit is contained in:
Jiang Bohan 2026-02-09 15:54:10 +08:00
parent d6993000ca
commit a8e7a803c9
8 changed files with 244 additions and 69 deletions

View file

@ -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

View file

@ -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();
});

View file

@ -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.
*/

View file

@ -87,6 +87,7 @@ export class Agent {
// Internal run state
private _internalRun = false;
private _runMutex: Promise<void> = 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<AgentRunResult> {
async run(
prompt: string,
options?: { displayPrompt?: string },
): Promise<AgentRunResult> {
// 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<AgentRunResult> {
private async _run(
prompt: string,
options?: { displayPrompt?: string },
): Promise<AgentRunResult> {
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.

View file

@ -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<SessionEntry, { type: "message" }>
>;
expect(entries).toHaveLength(1);
expect(entries[0]?.displayContent).toBe("save me");
});
});

View file

@ -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<SessionEntry, { type: "message" }>;
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 },
),

View file

@ -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";

View file

@ -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