fix(agent): separate display content from agent user turns
This commit is contained in:
parent
d6993000ca
commit
a8e7a803c9
8 changed files with 244 additions and 69 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
96
src/agent/session/session-manager.display.test.ts
Normal file
96
src/agent/session/session-manager.display.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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 },
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue