diff --git a/apps/desktop/electron/electron-env.d.ts b/apps/desktop/electron/electron-env.d.ts index 5050eed0..09ea4db7 100644 --- a/apps/desktop/electron/electron-env.d.ts +++ b/apps/desktop/electron/electron-env.d.ts @@ -81,7 +81,6 @@ interface SkillAddResult { interface ProfileData { profileId: string | undefined name: string | undefined - style: string | undefined userContent: string | undefined } @@ -184,7 +183,6 @@ interface ElectronAPI { profile: { get: () => Promise updateName: (name: string) => Promise - updateStyle: (style: string) => Promise updateUser: (content: string) => Promise } provider: { 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/apps/desktop/electron/ipc/profile.ts b/apps/desktop/electron/ipc/profile.ts index d6dd4dac..36ac1512 100644 --- a/apps/desktop/electron/ipc/profile.ts +++ b/apps/desktop/electron/ipc/profile.ts @@ -25,7 +25,6 @@ function getDefaultAgent() { export interface ProfileData { profileId: string | undefined name: string | undefined - style: string | undefined userContent: string | undefined } @@ -42,7 +41,6 @@ export function registerProfileIpcHandlers(): void { return { profileId: undefined, name: undefined, - style: undefined, userContent: undefined, } } @@ -50,7 +48,6 @@ export function registerProfileIpcHandlers(): void { return { profileId: agent.getProfileId(), name: agent.getAgentName(), - style: agent.getAgentStyle(), userContent: agent.getUserContent(), } }) @@ -92,19 +89,4 @@ export function registerProfileIpcHandlers(): void { return { ok: true } }) - /** - * Update agent communication style. - */ - ipcMain.handle('profile:updateStyle', async (_event, style: string) => { - const agent = getDefaultAgent() - if (!agent) { - return { error: 'No agent available' } - } - - agent.setAgentStyle(style) - // Reload system prompt to apply changes immediately - agent.reloadSystemPrompt() - - return { ok: true, style } - }) } diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index de7a78fa..60d6a2fc 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -40,7 +40,6 @@ export interface SkillInfo { export interface ProfileData { profileId: string | undefined name: string | undefined - style: string | undefined userContent: string | undefined } @@ -93,10 +92,6 @@ export interface LocalChatApproval { expiresAtMs: number } -// Available style options -export const AGENT_STYLES = ['concise', 'warm', 'playful', 'professional'] as const -export type AgentStyle = (typeof AGENT_STYLES)[number] - // ============================================================================ // Expose typed API to Renderer process // ============================================================================ @@ -174,7 +169,6 @@ const electronAPI = { profile: { get: (): Promise => ipcRenderer.invoke('profile:get'), updateName: (name: string) => ipcRenderer.invoke('profile:updateName', name), - updateStyle: (style: string) => ipcRenderer.invoke('profile:updateStyle', style), updateUser: (content: string) => ipcRenderer.invoke('profile:updateUser', content), }, diff --git a/apps/desktop/src/components/agent-settings-dialog.tsx b/apps/desktop/src/components/agent-settings-dialog.tsx index 5414a7c2..bcd28a17 100644 --- a/apps/desktop/src/components/agent-settings-dialog.tsx +++ b/apps/desktop/src/components/agent-settings-dialog.tsx @@ -12,15 +12,7 @@ import { Input } from '@multica/ui/components/ui/input' import { Textarea } from '@multica/ui/components/ui/textarea' import { Label } from '@multica/ui/components/ui/label' import { HugeiconsIcon } from '@hugeicons/react' -import { Loading03Icon, Tick02Icon } from '@hugeicons/core-free-icons' - -// Style options with labels -const STYLE_OPTIONS = [ - { value: 'concise', label: 'Concise', description: 'Brief and to the point' }, - { value: 'warm', label: 'Warm', description: 'Friendly and approachable' }, - { value: 'playful', label: 'Playful', description: 'Fun and lighthearted' }, - { value: 'professional', label: 'Professional', description: 'Formal and business-like' }, -] as const +import { Loading03Icon } from '@hugeicons/core-free-icons' interface AgentSettingsDialogProps { open: boolean @@ -31,7 +23,6 @@ export function AgentSettingsDialog({ open, onOpenChange }: AgentSettingsDialogP const [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false) const [name, setName] = useState('') - const [style, setStyle] = useState('concise') const [userContent, setUserContent] = useState('') // Load profile data when dialog opens @@ -46,7 +37,6 @@ export function AgentSettingsDialog({ open, onOpenChange }: AgentSettingsDialogP try { const data = await window.electronAPI.profile.get() setName(data.name ?? '') - setStyle(data.style ?? 'concise') setUserContent(data.userContent ?? '') } catch (err) { console.error('Failed to load profile:', err) @@ -60,8 +50,6 @@ export function AgentSettingsDialog({ open, onOpenChange }: AgentSettingsDialogP try { // Update name if changed await window.electronAPI.profile.updateName(name) - // Update style - await window.electronAPI.profile.updateStyle(style) // Update user content await window.electronAPI.profile.updateUser(userContent) onOpenChange(false) @@ -78,7 +66,7 @@ export function AgentSettingsDialog({ open, onOpenChange }: AgentSettingsDialogP Edit Agent - Customize your agent's name, style and personal settings. + Customize your agent's name and personal settings. @@ -99,35 +87,6 @@ export function AgentSettingsDialog({ open, onOpenChange }: AgentSettingsDialogP /> - {/* Style */} -
- -
- {STYLE_OPTIONS.map((option) => ( - - ))} -
-
- {/* User Content */}
diff --git a/src/agent/async-agent.test.ts b/src/agent/async-agent.test.ts index f50cefc8..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(); @@ -58,10 +62,6 @@ vi.mock("./runner.js", () => ({ return undefined; } setUserContent() {} - getAgentStyle() { - return undefined; - } - setAgentStyle() {} reloadSystemPrompt() {} getProviderInfo() { return { provider: "test", model: "test-model" }; @@ -107,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(); }); @@ -119,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 7d322d14..80cd45c6 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(); @@ -318,20 +318,6 @@ export class AsyncAgent { this.agent.setUserContent(content); } - /** - * Get agent communication style from profile config. - */ - getAgentStyle(): string | undefined { - return this.agent.getAgentStyle(); - } - - /** - * Update agent communication style in profile config. - */ - setAgentStyle(style: string): void { - this.agent.setAgentStyle(style); - } - /** * Reload profile from disk and rebuild system prompt. * Call this after updating profile files to apply changes immediately. @@ -360,6 +346,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/profile/index.ts b/src/agent/profile/index.ts index 43a67d51..e78838d3 100644 --- a/src/agent/profile/index.ts +++ b/src/agent/profile/index.ts @@ -297,59 +297,4 @@ export class ProfileManager { } } - /** 获取 Agent 风格 */ - getStyle(): string | undefined { - const profile = this.getProfile(); - return profile?.config?.style; - } - - /** 更新 Agent 风格 */ - updateStyle(style: string): void { - const profile = this.getOrCreateProfile(false); - const currentConfig = profile.config ?? {}; - // Use Object.assign to avoid exactOptionalPropertyTypes issues with spread - const newConfig: ProfileConfig = Object.assign({}, currentConfig, { - style: style as ProfileConfig["style"], - }); - profile.config = newConfig; - this.profile = profile; - writeProfileConfig(this.profileId, newConfig, { baseDir: this.baseDir }); - - // Also update soul.md to include the style - this.updateSoulWithStyle(style); - } - - /** 更新 soul.md,确保包含 Agent 风格 */ - private updateSoulWithStyle(style: string): void { - const profile = this.getOrCreateProfile(true); - let soulContent = profile.soul ?? DEFAULT_TEMPLATES.soul; - - // 替换 soul.md 中的 Style 字段 - // 匹配 "- **Style:** xxx" 格式 - const stylePattern = /- \*\*Style:\*\* .*/; - const newStyleLine = `- **Style:** ${style}`; - - if (stylePattern.test(soulContent)) { - soulContent = soulContent.replace(stylePattern, newStyleLine); - } else { - // 如果没有找到 Style 字段,在 Identity 部分的 Role 后添加 - const rolePattern = /(- \*\*Role:\*\* .*)/; - if (rolePattern.test(soulContent)) { - soulContent = soulContent.replace(rolePattern, `$1\n${newStyleLine}`); - } else { - // 如果没有 Role,尝试在 Name 后添加 - const namePattern = /(- \*\*Name:\*\* .*)/; - if (namePattern.test(soulContent)) { - soulContent = soulContent.replace(namePattern, `$1\n${newStyleLine}`); - } - } - } - - // 保存更新后的 soul.md - writeProfileFile(this.profileId, PROFILE_FILES.soul, soulContent, { baseDir: this.baseDir }); - // 更新缓存 - if (this.profile) { - this.profile.soul = soulContent; - } - } } diff --git a/src/agent/profile/types.ts b/src/agent/profile/types.ts index a250cf91..228a0788 100644 --- a/src/agent/profile/types.ts +++ b/src/agent/profile/types.ts @@ -15,22 +15,10 @@ export const PROFILE_FILES = { config: "config.json", } as const; -/** Available style options for agent personality */ -export const AGENT_STYLES = [ - "concise", // 简洁直接 - "warm", // 温暖友好 - "playful", // 轻松活泼 - "professional", // 专业正式 -] as const; - -export type AgentStyle = (typeof AGENT_STYLES)[number]; - /** Profile config.json structure */ export interface ProfileConfig { /** Agent display name */ name?: string; - /** Agent communication style */ - style?: AgentStyle; /** Tools policy configuration */ tools?: ToolsConfig; /** Default LLM provider */ diff --git a/src/agent/runner.ts b/src/agent/runner.ts index afb59144..8738163b 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -1,4 +1,5 @@ import { Agent as PiAgentCore, type AgentEvent, type AgentMessage } from "@mariozechner/pi-agent-core"; +import type { UserMessage } from "@mariozechner/pi-ai"; import { v7 as uuidv7 } from "uuid"; import type { AgentOptions, AgentRunResult, ReasoningMode } from "./types.js"; import type { MulticaEvent, CompactionEndEvent } from "./events.js"; @@ -87,6 +88,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 @@ -364,9 +366,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)); } /** @@ -406,70 +411,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; + } } /** @@ -560,7 +573,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?: UserMessage["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) { @@ -689,6 +709,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. @@ -807,20 +835,6 @@ export class Agent { this.profile?.updateUserContent(content); } - /** - * Get agent communication style from profile config. - */ - getAgentStyle(): string | undefined { - return this.profile?.getStyle(); - } - - /** - * Update agent communication style in profile config. - */ - setAgentStyle(style: string): void { - this.profile?.updateStyle(style); - } - /** * Get current provider and model information. */ 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..8ca825dd 100644 --- a/src/agent/session/session-manager.ts +++ b/src/agent/session/session-manager.ts @@ -1,5 +1,5 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import { getModel, type Model } from "@mariozechner/pi-ai"; +import { getModel, type Model, type UserMessage } from "@mariozechner/pi-ai"; import type { SessionEntry, SessionMeta } from "./types.js"; import { appendEntry, readEntries, resolveSessionPath, writeEntries } from "./storage.js"; import { compactMessages, compactMessagesAsync, type CompactionResult } from "./compaction.js"; @@ -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?: UserMessage["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..ec734424 100644 --- a/src/agent/session/types.ts +++ b/src/agent/session/types.ts @@ -1,4 +1,5 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import type { UserMessage } from "@mariozechner/pi-ai"; export type SessionMeta = { provider?: string; @@ -11,7 +12,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?: UserMessage["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