diff --git a/packages/gateway-protocol/src/schema/openclaw.ts b/packages/gateway-protocol/src/schema/openclaw.ts index 3772efc8f7ca..c76408ac44bc 100644 --- a/packages/gateway-protocol/src/schema/openclaw.ts +++ b/packages/gateway-protocol/src/schema/openclaw.ts @@ -125,7 +125,7 @@ export const SystemAgentChatHistoryTurnSchema = closedObject({ role: Type.Union([Type.Literal("user"), Type.Literal("assistant")]), text: Type.String(), at: Type.Number(), - /** Present only on accepted typed controls from a live recovered session. */ + /** Present only on accepted typed controls; values and sensitive prompts are excluded. */ wizardAction: Type.Optional(SystemAgentChatHistoryWizardActionSchema), }); diff --git a/src/gateway/server-methods/system-agent-chat-history.test.ts b/src/gateway/server-methods/system-agent-chat-history.test.ts index 16e8b177a800..fc10d36d104e 100644 --- a/src/gateway/server-methods/system-agent-chat-history.test.ts +++ b/src/gateway/server-methods/system-agent-chat-history.test.ts @@ -70,7 +70,7 @@ describe("openclaw.chat.history wizard recovery", () => { transcriptStoreMocks.readTranscriptTail.mockReset().mockReturnValue(turns); }); - it("keeps accepted action metadata on live recovery turns only", () => { + it("keeps accepted action metadata on live and durable recovery turns", () => { const wizardAction = { kind: "cancel" as const, }; @@ -95,12 +95,10 @@ describe("openclaw.chat.history wizard recovery", () => { }), ]); expect(vi.mocked(appendTranscriptTurn).mock.calls.map(([turn]) => turn)).toEqual([ - expect.objectContaining({ role: "user", text: "Cancel" }), + expect.objectContaining({ role: "user", text: "Cancel", wizardAction }), expect.objectContaining({ role: "assistant", text: "Twitch setup cancelled." }), ]); - for (const [turn] of vi.mocked(appendTranscriptTurn).mock.calls) { - expect(turn).not.toHaveProperty("wizardAction"); - } + expect(vi.mocked(appendTranscriptTurn).mock.calls[1]?.[0]).not.toHaveProperty("wizardAction"); }); it("omits action metadata when the engine rejects the typed answer", () => { @@ -178,6 +176,16 @@ describe("openclaw.chat.history wizard recovery", () => { }); it("falls back to the global audit history after a Gateway reload", async () => { + const durableTurns = [ + { role: "assistant" as const, text: "Choose one.", at: 1 }, + { + role: "user" as const, + text: "Alpha", + at: 2, + wizardAction: { kind: "answer" as const, prompt: "Choose one" }, + }, + ]; + transcriptStoreMocks.readTranscriptTail.mockReturnValue(durableTurns); const invocation = makeInvocation({ sessionId: "recover-session" }); invocation.context.systemAgentSessions.clear(); @@ -188,7 +196,7 @@ describe("openclaw.chat.history wizard recovery", () => { expect(invocation.calls).toEqual([ { ok: true, - payload: { turns }, + payload: { turns: durableTurns }, error: undefined, }, ]); diff --git a/src/gateway/server-methods/system-agent-chat-history.ts b/src/gateway/server-methods/system-agent-chat-history.ts index 890e41e5556e..46e1a58acf1f 100644 --- a/src/gateway/server-methods/system-agent-chat-history.ts +++ b/src/gateway/server-methods/system-agent-chat-history.ts @@ -51,7 +51,7 @@ export function persistSystemAgentEngineHistory( for (const turn of engine.historySince(startIndex)) { const action = turn.role === "user" ? wizardAction : undefined; const recoveryTurn = { ...turn, at, ...(action ? { wizardAction: action } : {}) }; - appendTranscriptTurn({ ...turn, at }); + appendTranscriptTurn(recoveryTurn); recoveryTurns.push(recoveryTurn); if (action) { wizardAction = undefined; diff --git a/src/gateway/server-methods/system-agent-reset-boundary.test.ts b/src/gateway/server-methods/system-agent-reset-boundary.test.ts index 79e5c6090909..94d3923f1c61 100644 --- a/src/gateway/server-methods/system-agent-reset-boundary.test.ts +++ b/src/gateway/server-methods/system-agent-reset-boundary.test.ts @@ -4,6 +4,11 @@ import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { + SystemAgentChatHistoryTurn, + SystemAgentChatParams, + SystemAgentChatResult, +} from "../../../packages/gateway-protocol/src/index.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { resetCommandQueueStateForTest } from "../../process/command-queue.test-support.js"; import { closeOpenClawStateDatabase } from "../../state/openclaw-state-db.js"; @@ -166,7 +171,11 @@ describe("openclaw.chat reset boundary", () => { { wizardDependencies: { runChannelSetupWizard: async (_channel, prompter) => { - await prompter.text({ message: "Bot token" }); + await prompter.text({ + message: "Port", + validate: (value) => (value === "18789" ? undefined : "Enter port 18789"), + }); + await prompter.text({ message: "Bot token", sensitive: true }); }, }, }, @@ -183,29 +192,29 @@ describe("openclaw.chat reset boundary", () => { ], ]); const context = { systemAgentSessions: sessions } as unknown as GatewayRequestContext; - const chatResponses: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = []; + const sendChat = async (params: SystemAgentChatParams): Promise => { + let result: SystemAgentChatResult | undefined; + await expectDefined( + systemAgentHandlers["openclaw.chat"], + 'systemAgentHandlers["openclaw.chat"] test invariant', + )({ + params, + client, + context, + respond: (ok: boolean, payload?: unknown, error?: unknown) => { + expect(error).toBeUndefined(); + expect(ok).toBe(true); + result = payload as SystemAgentChatResult; + }, + } as never); + return expectDefined(result, "expected chat result"); + }; - await expectDefined( - systemAgentHandlers["openclaw.chat"], - 'systemAgentHandlers["openclaw.chat"] test invariant', - )({ - params: { sessionId: "recover-session", message: "connect telegram" }, - client, - context, - respond: (ok: boolean, payload?: unknown, error?: unknown) => - chatResponses.push({ ok, payload, error }), - } as never); - - expect(chatResponses).toEqual([ - { - ok: true, - payload: expect.objectContaining({ - wizardInputPending: true, - step: expect.objectContaining({ message: "Bot token" }), - }), - error: undefined, - }, - ]); + const prompt = await sendChat({ sessionId: "recover-session", message: "connect telegram" }); + expect(prompt).toMatchObject({ + wizardInputPending: true, + step: { message: "Port" }, + }); const historyResponses: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = []; await expectDefined( systemAgentHandlers["openclaw.chat.history"], @@ -229,6 +238,52 @@ describe("openclaw.chat reset boundary", () => { }, }); expect(historyResponses[0]).not.toHaveProperty("payload.turns.0.sessionId"); + const portStepId = expectDefined(prompt.step?.id, "expected port step"); + const rejected = await sendChat({ + sessionId: "recover-session", + wizardAnswer: { stepId: portStepId, value: "banana" }, + }); + expect(rejected.wizardActionAccepted).toBe(false); + const accepted = await sendChat({ + sessionId: "recover-session", + wizardAnswer: { stepId: portStepId, value: "18789" }, + }); + expect(accepted.wizardActionAccepted).toBe(true); + const secretStepId = expectDefined(accepted.step?.id, "expected sensitive step"); + const cancelled = await sendChat({ + sessionId: "recover-session", + wizardCancel: { stepId: secretStepId }, + }); + expect(cancelled.wizardActionAccepted).toBe(true); + + closeOpenClawStateDatabase(); + sessions.clear(); + historyResponses.length = 0; + await expectDefined( + systemAgentHandlers["openclaw.chat.history"], + 'systemAgentHandlers["openclaw.chat.history"] test invariant', + )({ + params: { sessionId: "recover-session" }, + client, + context, + respond: (ok: boolean, payload?: unknown, error?: unknown) => + historyResponses.push({ ok, payload, error }), + } as never); + + expect(historyResponses[0]).toMatchObject({ ok: true, error: undefined }); + expect(historyResponses[0]).not.toHaveProperty("payload.activeWizard"); + const restoredTurns = ( + historyResponses[0]?.payload as { turns: SystemAgentChatHistoryTurn[] } + ).turns; + expect(restoredTurns.find((turn) => turn.text === "banana")).not.toHaveProperty( + "wizardAction", + ); + expect(restoredTurns.find((turn) => turn.text === "18789")).toMatchObject({ + wizardAction: { kind: "answer", prompt: "Port" }, + }); + const restoredCancel = restoredTurns.find((turn) => turn.text === "Cancel"); + expect(restoredCancel).toMatchObject({ wizardAction: { kind: "cancel" } }); + expect(restoredCancel?.wizardAction).not.toHaveProperty("prompt"); }); }); diff --git a/src/system-agent/transcript-store.ts b/src/system-agent/transcript-store.ts index 7e47bdb04f6e..e0ea8593f21c 100644 --- a/src/system-agent/transcript-store.ts +++ b/src/system-agent/transcript-store.ts @@ -1,16 +1,11 @@ // Durable rolling transcript for the machine-wide OpenClaw conversation. import { randomUUID } from "node:crypto"; +import type { SystemAgentChatHistoryTurn } from "../../packages/gateway-protocol/src/index.js"; import { createSqliteAuditRecordStore } from "../infra/sqlite-audit-record-store.js"; -type SystemAgentTranscriptEntry = { - role: "user" | "assistant" | "reset"; - text: string; - at: number; -}; - -type SystemAgentTranscriptTurn = Omit & { - role: "user" | "assistant"; -}; +type SystemAgentTranscriptEntry = + | SystemAgentChatHistoryTurn + | { role: "reset"; text: ""; at: number }; const SYSTEM_AGENT_TRANSCRIPT_SCOPE = "system-agent-transcript"; const SYSTEM_AGENT_TRANSCRIPT_MAX_ENTRIES = 1_000; @@ -43,7 +38,7 @@ export function appendTranscriptReset(opts: { env?: NodeJS.ProcessEnv } = {}): v export function readTranscriptTail( limit: number, opts: { afterLastReset?: boolean; env?: NodeJS.ProcessEnv } = {}, -): SystemAgentTranscriptTurn[] { +): SystemAgentChatHistoryTurn[] { const entries = openTranscriptStore(opts.env) .latest({ limit }) .toReversed() @@ -52,5 +47,5 @@ export function readTranscriptTail( ? entries.findLastIndex((turn) => turn.role === "reset") : -1; const window = opts.afterLastReset ? entries.slice(resetIndex + 1) : entries; - return window.filter((turn): turn is SystemAgentTranscriptTurn => turn.role !== "reset"); + return window.filter((turn): turn is SystemAgentChatHistoryTurn => turn.role !== "reset"); } diff --git a/ui/src/pages/custodian/transcript.ts b/ui/src/pages/custodian/transcript.ts index 4faf5ecb6428..2fe959c44523 100644 --- a/ui/src/pages/custodian/transcript.ts +++ b/ui/src/pages/custodian/transcript.ts @@ -127,15 +127,10 @@ function createCustodianTranscriptMessages( ? t("custodian.sensitiveReply") : turn.text; if (turn.role === "user" && turn.wizardAction) { - const previous = messages.at(-1); - const supportingText = previous?.role === "assistant" ? previous.text : ""; - if (supportingText) { - messages.pop(); - } messages.push({ id: nextMessageId++, role: "assistant", - text: supportingText, + text: "", at: turn.at, question: null, step: null,