diff --git a/packages/core/src/agent/agent-loop.ts b/packages/core/src/agent/agent-loop.ts index 6c62d3bf..b9206fc3 100644 --- a/packages/core/src/agent/agent-loop.ts +++ b/packages/core/src/agent/agent-loop.ts @@ -946,7 +946,7 @@ export class AgentLoop { if (message.role === "user") { this.memory.addUser(content); } else { - this.memory.addSystem(content); + this.memory.addSystem(content, { hidden: message.hidden }); } } diff --git a/packages/core/src/agent/conversation-memory.test.ts b/packages/core/src/agent/conversation-memory.test.ts index abf4f3f1..757070e2 100644 --- a/packages/core/src/agent/conversation-memory.test.ts +++ b/packages/core/src/agent/conversation-memory.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import type { SystemMessage } from "@step-cli/protocol"; import { ConversationMemory, type MemoryConfig, @@ -100,6 +101,23 @@ describe("ConversationMemory", () => { expect(state.messages).toHaveLength(1); expect(state.messages[0]!.role).toBe("system"); }); + + it("preserves hidden flag through export/load round-trip", () => { + const memory = new ConversationMemory(makeConfig()); + memory.addSystem("hidden instruction", { hidden: true }); + memory.addSystem("visible instruction"); + + const exported = memory.exportState(); + const memory2 = new ConversationMemory(makeConfig()); + memory2.loadState(exported); + + const restored = memory2.exportState(); + expect(restored.messages).toHaveLength(2); + expect(restored.messages[0]!.role).toBe("system"); + expect((restored.messages[0]! as SystemMessage).hidden).toBe(true); + expect(restored.messages[1]!.role).toBe("system"); + expect((restored.messages[1]! as SystemMessage).hidden).toBeUndefined(); + }); }); describe("recordDecision", () => { diff --git a/packages/core/src/agent/conversation-memory.ts b/packages/core/src/agent/conversation-memory.ts index bd0f2849..a070b4c4 100644 --- a/packages/core/src/agent/conversation-memory.ts +++ b/packages/core/src/agent/conversation-memory.ts @@ -356,8 +356,12 @@ export class ConversationMemory { this.invalidateContextAssemblyForTranscriptMutation(); } - addSystem(content: string): void { - this.messages.push({ role: "system", content }); + addSystem(content: string, options?: { hidden?: boolean }): void { + this.messages.push({ + role: "system", + content, + ...(options?.hidden ? { hidden: true } : undefined), + }); this.invalidateContextAssemblyForTranscriptMutation(); const normalized = shortenLine(content, this.config.decisionEntryMaxChars); if (normalized.length > 0) { @@ -2048,6 +2052,7 @@ function cloneChatMessage(message: ChatMessage): ChatMessage { return { role: "system", content: message.content, + ...(message.hidden ? { hidden: true } : undefined), }; } diff --git a/packages/core/src/plugins/manager.ts b/packages/core/src/plugins/manager.ts index 448ae11b..15e595e8 100644 --- a/packages/core/src/plugins/manager.ts +++ b/packages/core/src/plugins/manager.ts @@ -208,10 +208,15 @@ export class PluginManager { strategy: "head_tail", }); - injected.push({ - role: message.role, - content: truncated.text, - }); + injected.push( + message.role === "system" + ? { + role: "system", + content: truncated.text, + ...(message.hidden ? { hidden: true } : undefined), + } + : { role: "user", content: truncated.text }, + ); } if (injected.length >= MAX_TOTAL_INJECTED_MESSAGES) { diff --git a/packages/core/src/plugins/types.ts b/packages/core/src/plugins/types.ts index 03782f84..782d06ce 100644 --- a/packages/core/src/plugins/types.ts +++ b/packages/core/src/plugins/types.ts @@ -30,10 +30,21 @@ export interface PluginUserMessage { content: string; } -export type PluginInjectedMessage = { - role: "system" | "user"; - content: string; -}; +export type PluginInjectedMessage = + | { + role: "system"; + content: string; + /** + * When true, the message is only for the model context and should not + * be rendered in the UI. Only system messages support this flag; the + * agent loop stores injected user messages without it. + */ + hidden?: boolean; + } + | { + role: "user"; + content: string; + }; export interface PluginHookContext { workspaceRoot: string; diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index b1216184..888fa1c2 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -39,6 +39,8 @@ export interface JsonSchema { export interface SystemMessage { role: "system"; content: string; + /** Internal messages injected by plugins; should not be rendered in the UI. */ + hidden?: boolean; } export type UserAttachmentSource = diff --git a/skills/builtin/src/plan-plugin.ts b/skills/builtin/src/plan-plugin.ts index 46e223cc..5b71e4d5 100644 --- a/skills/builtin/src/plan-plugin.ts +++ b/skills/builtin/src/plan-plugin.ts @@ -135,6 +135,7 @@ export function createPlanPlugin(manager: PlanManager): ToolPlugin { { role: "system", content: renderPlanInjectedMessage(snapshot), + hidden: true, }, ], }; diff --git a/skills/builtin/src/skill-plugin.ts b/skills/builtin/src/skill-plugin.ts index 7527a30b..5b6ad9a8 100644 --- a/skills/builtin/src/skill-plugin.ts +++ b/skills/builtin/src/skill-plugin.ts @@ -103,6 +103,7 @@ export function createSkillPlugin( { role: "system" as const, content: renderInjectedSkills(injectedContents), + hidden: true, }, ] : undefined; diff --git a/skills/builtin/src/subagent-plugin.ts b/skills/builtin/src/subagent-plugin.ts index d5d6369f..def6af87 100644 --- a/skills/builtin/src/subagent-plugin.ts +++ b/skills/builtin/src/subagent-plugin.ts @@ -1268,6 +1268,7 @@ class BackgroundSubtaskManager { messages.push({ role: "system", content: MAIN_ORCHESTRATION_REMINDER, + hidden: true, }); } diff --git a/src/runtime/local-opentui-bridge.ts b/src/runtime/local-opentui-bridge.ts index 3400b6b7..6374be43 100644 --- a/src/runtime/local-opentui-bridge.ts +++ b/src/runtime/local-opentui-bridge.ts @@ -90,7 +90,7 @@ export class LocalOpenTuiTranscriptBridge implements StepCliTuiTranscriptControl return [ ...this.sessionEntries, ...this.localEntries.map(stripLocalTranscriptEntry), - ]; + ].filter((entry) => !entry.hidden); } subscribe( @@ -900,6 +900,7 @@ function mapChatMessageToTranscriptEntry( role: "system", caption: null, content: message.content, + hidden: message.hidden, }; } } diff --git a/src/tui/app.tsx b/src/tui/app.tsx index 2d7a5347..82880d8f 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -1914,21 +1914,23 @@ function buildTranscriptItems( ): TranscriptItem[] { return [ buildWelcomeTranscriptItem(width), - ...entries.map((entry, index) => { - const identity = resolveTranscriptIdentity(entry); - const body = compactToolTranscriptContent(entry); - const lines = wrapMultiline(body, Math.max(12, width - 4)); - return { - id: - entry.id || - `message:${index}:${identity.badge}:${identity.caption ?? ""}`, - ...identity, - backgroundColor: resolveTranscriptBackground(entry, theme), - border: false, - lines, - truncated: false, - }; - }), + ...entries + .filter((entry) => !entry.hidden) + .map((entry, index) => { + const identity = resolveTranscriptIdentity(entry); + const body = compactToolTranscriptContent(entry); + const lines = wrapMultiline(body, Math.max(12, width - 4)); + return { + id: + entry.id || + `message:${index}:${identity.badge}:${identity.caption ?? ""}`, + ...identity, + backgroundColor: resolveTranscriptBackground(entry, theme), + border: false, + lines, + truncated: false, + }; + }), ]; } diff --git a/src/tui/transcript-export.ts b/src/tui/transcript-export.ts index 085acd5f..71d2237a 100644 --- a/src/tui/transcript-export.ts +++ b/src/tui/transcript-export.ts @@ -4,6 +4,7 @@ export function buildTranscriptClipboardText( entries: readonly StepCliTuiTranscriptEntry[], ): string { return entries + .filter((entry) => !entry.hidden) .map((entry) => formatTranscriptClipboardBlock(entry)) .filter((block) => block.length > 0) .join("\n\n") diff --git a/src/tui/types.ts b/src/tui/types.ts index cebe79f1..adf838f9 100644 --- a/src/tui/types.ts +++ b/src/tui/types.ts @@ -87,6 +87,8 @@ export interface StepCliTuiTranscriptEntry { role: "assistant" | "user" | "tool" | "system"; content: string; caption: string | null; + /** Internal message that should not be rendered in the transcript. */ + hidden?: boolean; } export interface StepCliTuiQueuedTurnEntry {