From 3dc3370d9e085c2553a78f7095f2da2cdbe42d02 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Jul 2026 15:42:00 -0400 Subject: [PATCH] fix(copilot): keep tool transcript groups structurally complete (#114403) * refactor(copilot): make SQLite own transcripts * fix(copilot): harden transcript event projection * test(copilot): tighten transcript journal types * fix(copilot): retain unmatched usage metadata * refactor(copilot): commit tool groups atomically * fix(copilot): break journal bridge import cycle * fix(copilot): preserve grouped transcript replay * test(copilot): cover opaque replay state * fix(copilot): invalidate lossy transcript replay * fix(copilot): guard transcript rewrite topology * fix(copilot): drain transcript finalization * fix(copilot): preserve resumed session history * fix(copilot): dedupe replayed transcript snapshots * fix(copilot): isolate transcript write hooks * fix(copilot): track staged transcript identities * fix(copilot): isolate ephemeral transcript events * fix(copilot): coalesce assistant snapshots * fix(copilot): invalidate unprojected SDK context * fix(copilot): guard resumed transcript replay * fix(plugin-sdk): expose strict transcript append * fix(copilot): persist journal provenance safely * fix(copilot): separate live and durable deltas * fix(copilot): require durable projection ownership * fix(copilot): preserve strict append typing * fix(copilot): gate deferred session deletion * fix(copilot): preserve transcript replay identity * fix(copilot): retain cited replay history * fix(copilot): require complete replay provenance * fix(copilot): reject ambiguous assistant snapshots * fix(copilot): return latest assistant snapshot * chore(copilot): align transcript lint gates * test(lint): cover Copilot line budgets * test(ci): dedupe Codex prewarm shard --- .oxlintrc.json | 12 + extensions/copilot/harness.test.ts | 40 +- extensions/copilot/harness.ts | 50 +- extensions/copilot/src/attempt-cleanup.ts | 7 +- extensions/copilot/src/attempt-config.ts | 55 +- extensions/copilot/src/attempt-execution.ts | 68 +- extensions/copilot/src/attempt-finalize.ts | 194 +-- .../src/attempt-transcript-journal.test.ts | 1263 +++++++++++++++++ .../copilot/src/attempt-transcript-journal.ts | 620 ++++++++ extensions/copilot/src/attempt-types.ts | 10 +- extensions/copilot/src/attempt.test.ts | 1049 +++++++++----- .../src/dual-write-transcripts.test.ts | 502 ------- .../copilot/src/dual-write-transcripts.ts | 247 ---- .../copilot/src/event-bridge-transcript.ts | 167 +++ extensions/copilot/src/event-bridge.test.ts | 109 +- extensions/copilot/src/event-bridge.ts | 504 ++++++- .../command/attempt-execution.cli.test.ts | 46 + src/agents/command/attempt-execution.ts | 5 +- src/agents/command/post-run.ts | 8 + ...ession-accessor.sqlite-transcript-write.ts | 16 +- .../session-accessor.transcript-turn.ts | 44 +- src/config/sessions/session-accessor.ts | 5 +- .../session-transcript-runtime.test.ts | 70 + src/plugin-sdk/session-transcript-runtime.ts | 67 + test/scripts/oxlint-config.test.ts | 22 +- ...n-codex-app-server-attempt-extra.config.ts | 1 - 26 files changed, 3894 insertions(+), 1287 deletions(-) create mode 100644 extensions/copilot/src/attempt-transcript-journal.test.ts create mode 100644 extensions/copilot/src/attempt-transcript-journal.ts delete mode 100755 extensions/copilot/src/dual-write-transcripts.test.ts delete mode 100755 extensions/copilot/src/dual-write-transcripts.ts create mode 100644 extensions/copilot/src/event-bridge-transcript.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index 63057d70159c..62d4682d4f1e 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -362,6 +362,18 @@ "rules": { "max-lines": ["error", { "max": 1000, "skipBlankLines": true, "skipComments": true }] } + }, + { + "files": ["extensions/copilot/src/event-bridge.ts"], + "rules": { + "max-lines": ["error", { "max": 950, "skipBlankLines": true, "skipComments": true }] + } + }, + { + "files": ["extensions/copilot/src/attempt-transcript-journal.test.ts"], + "rules": { + "max-lines": ["error", { "max": 1200, "skipBlankLines": true, "skipComments": true }] + } } ] } diff --git a/extensions/copilot/harness.test.ts b/extensions/copilot/harness.test.ts index 431f94e08d65..4e830a0dfffd 100644 --- a/extensions/copilot/harness.test.ts +++ b/extensions/copilot/harness.test.ts @@ -947,7 +947,7 @@ describe("createCopilotAgentHarness", () => { sdkSessionId: "sdk-sess-warm", pooledClient: { key: TEST_POOL_KEY, client }, }); - return ATTEMPT_RESULT; + return { ...ATTEMPT_RESULT, journalValidated: true, sdkSessionId: "sdk-sess-warm" }; }); const harness = createCopilotAgentHarness({ pool }); @@ -956,13 +956,46 @@ describe("createCopilotAgentHarness", () => { expect(mocks.runCopilotAttempt).toHaveBeenCalledTimes(2); const secondCallParams = mocks.runCopilotAttempt.mock.calls[1]?.[0] as { - initialReplayState?: { sdkSessionId?: string; replayInvalid?: boolean }; + initialReplayState?: { + journalValidated?: boolean; + sdkSessionId?: string; + replayInvalid?: boolean; + }; }; expect(secondCallParams.initialReplayState?.sdkSessionId).toBe("sdk-sess-warm"); + expect(secondCallParams.initialReplayState?.journalValidated).toBe(true); // Must not synthesize a replayInvalid signal: undefined → resumable. expect(secondCallParams.initialReplayState?.replayInvalid).toBeUndefined(); }); + it("clears journal provenance before a resumed attempt and restores it after validation", async () => { + const pool = makePoolMock(); + const client = createMockCopilotClient({ deleteSession: vi.fn() }); + const sessionStore = makeSessionStoreMock(); + let call = 0; + mocks.runCopilotAttempt.mockImplementation(async (_params, deps) => { + call += 1; + deps.onSessionEstablished?.({ + sdkSessionId: "sdk-sess-provenance", + pooledClient: { key: TEST_POOL_KEY, client }, + }); + if (call === 2) { + expect(sessionStore.entries.get("oc-sess-reuse")?.journalVersion).toBeUndefined(); + } + return { + ...ATTEMPT_RESULT, + journalValidated: true, + sdkSessionId: "sdk-sess-provenance", + }; + }); + const harness = createCopilotAgentHarness({ pool, sessionStore: sessionStore.store }); + + await harness.runAttempt(makeAttemptParams({ runId: "t1" })); + await harness.runAttempt(makeAttemptParams({ runId: "t2" })); + + expect(sessionStore.entries.get("oc-sess-reuse")?.journalVersion).toBe(1); + }); + it("blocks reuse while timed-out compaction is pending, then resumes after completion", async () => { const pool = makePoolMock(); const sessionStore = makeSessionStoreMock(); @@ -1409,9 +1442,10 @@ describe("createCopilotAgentHarness", () => { }), ); const secondCallParams = mocks.runCopilotAttempt.mock.calls[1]?.[0] as { - initialReplayState?: { sdkSessionId?: string }; + initialReplayState?: { journalValidated?: boolean; sdkSessionId?: string }; }; expect(secondCallParams.initialReplayState?.sdkSessionId).toBe("sdk-sess-sqlite"); + expect(secondCallParams.initialReplayState?.journalValidated).toBeUndefined(); }); it("persists BYOK session compatibility with endpoint fingerprints instead of raw URLs", async () => { diff --git a/extensions/copilot/harness.ts b/extensions/copilot/harness.ts index 4b4f64a76234..2e6c24119668 100644 --- a/extensions/copilot/harness.ts +++ b/extensions/copilot/harness.ts @@ -44,6 +44,7 @@ interface CreateCopilotAgentHarnessOptions { } interface TrackedSession { + journalVersion?: 1; sdkSessionId: string; client: CopilotClient; clientOptions: ClientCreateOptions; @@ -91,6 +92,7 @@ interface CopilotHistoryCompactSession { export type CopilotSessionBinding = { schemaVersion: 2; + journalVersion?: 1; sdkSessionId: string; compatKey: string; compactKey: string; @@ -107,7 +109,10 @@ type LegacyCopilotSessionBinding = { updatedAt: number; }; -type CopilotAttemptSessionBinding = Pick; +type CopilotAttemptSessionBinding = Pick< + CopilotSessionBinding, + "compatKey" | "journalVersion" | "sdkSessionId" +>; type DeferredCompactionCleanupOutcome = "aborted" | "completed" | "deadline"; type DeferredCompactionCleanup = { abort: () => void; @@ -160,6 +165,7 @@ function normalizeBinding( value.compatKey.trim() === "" || typeof value.compactKey !== "string" || value.compactKey.trim() === "" || + (value.journalVersion !== undefined && value.journalVersion !== 1) || (value.authMode !== "gitHubToken" && value.authMode !== "byok" && value.authMode !== "useLoggedInUser") || @@ -175,6 +181,7 @@ function normalizeBinding( } return { schemaVersion: 2, + ...(value.journalVersion === 1 ? { journalVersion: 1 as const } : {}), sdkSessionId: value.sdkSessionId.trim(), compatKey: value.compatKey, compactKey: value.compactKey, @@ -704,12 +711,13 @@ export function createCopilotAgentHarness( ? undefined : lookupStoredBinding(options?.sessionStore, openclawSessionId) : undefined; - const resumableSessionId = + const resumableBinding = tracked && tracked.compatKey === currentCompatKey - ? tracked.sdkSessionId + ? tracked : !tracked && stored && stored.compatKey === currentCompatKey - ? stored.sdkSessionId + ? stored : undefined; + const resumableSessionId = resumableBinding?.sdkSessionId; if (operation === "settled-tool-finalization" && !resumableSessionId) { throw new Error( "[copilot] cannot safely finalize a settled tool turn without its compatible SDK session", @@ -739,15 +747,19 @@ export function createCopilotAgentHarness( // still requiring the exact compatible native session above. initialReplayState: operation === "settled-tool-finalization" - ? { sdkSessionId: resumableSessionId } + ? { + ...(resumableBinding?.journalVersion === 1 ? { journalValidated: true } : {}), + sdkSessionId: resumableSessionId, + } : { ...params.initialReplayState, + ...(resumableBinding?.journalVersion === 1 ? { journalValidated: true } : {}), sdkSessionId: resumableSessionId, }, } as AgentHarnessAttemptParams) : params; - return runCopilotAttempt(effectiveParams, { + const result = await runCopilotAttempt(effectiveParams, { pool, ...(operation === "settled-tool-finalization" ? { operation } : {}), onSessionEstablished: @@ -835,6 +847,32 @@ export function createCopilotAgentHarness( } : undefined, }); + if (operation === "attempt" && openclawSessionId) { + const attemptResult = result as AgentHarnessAttemptResult & { + journalValidated?: boolean; + sdkSessionId?: string; + }; + const sdkSessionId = attemptResult.sdkSessionId; + const trackedSession = trackedSessions.get(openclawSessionId); + if (sdkSessionId && trackedSession?.sdkSessionId === sdkSessionId) { + const { journalVersion: _journalVersion, ...baseTracked } = trackedSession; + const nextTracked: TrackedSession = { + ...baseTracked, + ...(attemptResult.journalValidated ? { journalVersion: 1 } : {}), + }; + trackedSessions.set(openclawSessionId, nextTracked); + registerStoredBinding(options?.sessionStore, openclawSessionId, { + schemaVersion: 2, + ...(attemptResult.journalValidated ? { journalVersion: 1 } : {}), + sdkSessionId, + compatKey: nextTracked.compatKey, + compactKey: nextTracked.compactKey, + ...sessionAuthFields(nextTracked), + updatedAt: Date.now(), + }); + } + } + return result; })(); inFlight.add(attemptPromise); try { diff --git a/extensions/copilot/src/attempt-cleanup.ts b/extensions/copilot/src/attempt-cleanup.ts index 82f9430046b4..52aaf1fecd5d 100644 --- a/extensions/copilot/src/attempt-cleanup.ts +++ b/extensions/copilot/src/attempt-cleanup.ts @@ -54,6 +54,7 @@ export function deferBackgroundCompactionCleanup(params: { pool: CopilotClientPool; cleanupByokProxy?: () => Promise; cleanupToolBridge?: () => void; + deleteSessionOnIncompleteCleanup: boolean; finalizeNativeSubagents?: () => void; sdkSessionId?: string; session: SessionLike; @@ -81,7 +82,11 @@ export function deferBackgroundCompactionCleanup(params: { } catch {} params.cleanupToolBridge?.(); await params.cleanupByokProxy?.(); - if (outcome !== "completed" && params.sdkSessionId) { + if ( + outcome !== "completed" && + params.deleteSessionOnIncompleteCleanup && + params.sdkSessionId + ) { try { await params.handle.client.deleteSession(params.sdkSessionId); } catch {} diff --git a/extensions/copilot/src/attempt-config.ts b/extensions/copilot/src/attempt-config.ts index 5f682e38a8ac..0a6f840402bd 100644 --- a/extensions/copilot/src/attempt-config.ts +++ b/extensions/copilot/src/attempt-config.ts @@ -30,6 +30,8 @@ export function createResult( params: AttemptParamsLike, state: { aborted?: boolean; + assistantTranscriptOwned?: boolean; + assistantTranscriptIdempotencyKey?: string; assistantTexts?: string[]; currentAttemptAssistant?: AssistantMessage; currentAttemptCompletedAssistant?: AssistantMessage; @@ -37,8 +39,10 @@ export function createResult( externalAbort?: boolean; itemLifecycle?: { activeCount: number; completedCount: number; startedCount: number }; lastAssistant?: AssistantMessage; + journalValidated?: boolean; lastToolError?: AgentHarnessAttemptResult["lastToolError"]; messagesSnapshot: AgentMessage[]; + nativeReplayInvalid?: boolean; now: () => number; promptError: Error | undefined; resumeFailureRecovered?: boolean; @@ -52,13 +56,21 @@ export function createResult( }, ): AttemptResultWithSdkSessionId { const promptError = state.promptError; + const transcriptPersistenceFailed = + (promptError as PromptErrorWithCode | undefined)?.code === "transcript_persistence_failed"; const timedOut = state.timedOut === true; const toolMetas = state.toolMetas ?? []; const replayMetadata = params.operation === "settled-tool-finalization" - ? { hadPotentialSideEffects: false, replaySafe: true } + ? { + hadPotentialSideEffects: false, + replaySafe: state.nativeReplayInvalid !== true && !transcriptPersistenceFailed, + } : computeReplayMetadata({ - priorReplayInvalid: params.initialReplayState?.replayInvalid, + priorReplayInvalid: + params.initialReplayState?.replayInvalid === true || + state.nativeReplayInvalid === true || + transcriptPersistenceFailed, priorHadPotentialSideEffects: params.initialReplayState?.hadPotentialSideEffects, thisAttemptTimedOut: timedOut, thisAttemptHadPotentialSideEffects: copilotToolMetasHavePotentialSideEffects(toolMetas), @@ -82,7 +94,16 @@ export function createResult( promptError !== undefined ? withPromptFailure(interruption, promptError) : interruption; return { terminal, + ...(state.assistantTranscriptOwned + ? { + assistantTranscriptOwned: true, + ...(state.assistantTranscriptIdempotencyKey + ? { assistantTranscriptIdempotencyKey: state.assistantTranscriptIdempotencyKey } + : {}), + } + : {}), ...(state.sdkSessionId ? { sdkSessionId: state.sdkSessionId } : {}), + ...(state.journalValidated !== undefined ? { journalValidated: state.journalValidated } : {}), assistantTexts: state.assistantTexts ?? [], attemptUsage: state.usage, cloudCodeAssistFormatError: false, @@ -334,36 +355,6 @@ export function createSystemMessageContent( export function isRawCopilotModelRun(params: AttemptParamsLike): boolean { return params.modelRun === true || params.promptMode === "none"; } -export function readTailUserText(messages: AgentMessage[]): string | undefined { - const tail = messages[messages.length - 1]; - if (!tail || tail.role !== "user") { - return undefined; - } - const content = (tail as { content?: unknown }).content; - if (typeof content === "string") { - return content; - } - if (Array.isArray(content)) { - for (const part of content) { - if (part && typeof part === "object" && (part as { type?: unknown }).type === "text") { - const text = (part as { text?: unknown }).text; - if (typeof text === "string" && text.length > 0) { - return text; - } - } - } - } - return undefined; -} -export function hasMirrorIdentity(message: AgentMessage): boolean { - const record = message as unknown as { __openclaw?: unknown }; - const meta = record["__openclaw"]; - if (!meta || typeof meta !== "object" || Array.isArray(meta)) { - return false; - } - const id = (meta as Record).mirrorIdentity; - return typeof id === "string" && id.length > 0; -} export function readString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } diff --git a/extensions/copilot/src/attempt-execution.ts b/extensions/copilot/src/attempt-execution.ts index 72dd289f1d2d..e07395849a9d 100644 --- a/extensions/copilot/src/attempt-execution.ts +++ b/extensions/copilot/src/attempt-execution.ts @@ -28,6 +28,10 @@ import { import { completeCopilotAttempt } from "./attempt-finalize.js"; import { resolveCopilotAttemptSandbox } from "./attempt-prepare.js"; import { createCopilotSessionSetup } from "./attempt-session-setup.js"; +import { + createAttemptTranscriptJournal, + type AttemptTranscriptJournal, +} from "./attempt-transcript-journal.js"; import type { AgentHarnessAttemptResult, AttemptParamsLike, @@ -94,10 +98,15 @@ export async function runCopilotExecution(context: { let promptError: Error | undefined; let sdkSessionId: string | undefined; let sessionIdUsed = input.sessionId; + // Resumed sessions may predate the atomic journal or survive a crash. Only a + // session created under this journal can be deleted after incomplete cleanup. + let nativeSessionCreatedFresh = false; + let nativeSessionHistoryValidated = false; let disconnectError: Error | undefined; let handle: PooledClient | undefined; let session: SessionLike | undefined; let bridge: ReturnType | undefined; + let transcriptJournal: AttemptTranscriptJournal | undefined; const nativeSubagentTaskMirror = createCopilotNativeSubagentTaskMirror({ agentId: sessionAgentId, now, @@ -337,6 +346,7 @@ export async function runCopilotExecution(context: { ...sessionConfig, continuePendingWork: false, })) as unknown as SessionLike; + nativeSessionHistoryValidated = input.initialReplayState?.journalValidated === true; } catch (error: unknown) { if (settledToolFinalization) { throw createPromptError( @@ -351,15 +361,25 @@ export async function runCopilotExecution(context: { } resumeFailureRecovered = true; session = (await client.createSession(sessionConfig)) as unknown as SessionLike; + nativeSessionCreatedFresh = true; + nativeSessionHistoryValidated = true; } } else { session = (await client.createSession(sessionConfig)) as unknown as SessionLike; + nativeSessionCreatedFresh = true; + nativeSessionHistoryValidated = true; } sessionRef.current = session; sdkSessionId = readString(session.sessionId) ?? readString(session.id) ?? (resumeFailureRecovered ? undefined : resumeSessionId); + if (!sdkSessionId) { + throw createPromptError( + "transcript_persistence_failed", + "[copilot-attempt] canonical transcript persistence requires the Copilot SDK session id", + ); + } sessionIdUsed = sdkSessionId ?? input.sessionId; if (sdkSessionId && deps.onSessionEstablished && !settledToolFinalization) { try { @@ -408,7 +428,17 @@ export async function runCopilotExecution(context: { }); }, getSdkSessionId: () => sdkSessionId, - isAborted: () => aborted, + isAborted: () => aborted || transcriptJournal?.hasFailed() === true, + transcriptProjection: { + journal: (transcriptJournal = createAttemptTranscriptJournal({ + abortSession: () => session?.abort() ?? Promise.resolve(), + attempt: input, + messages, + sdkSessionId, + })), + modelRef, + now, + }, }); activeRunHandleRef = registerCopilotActiveRun({ abortActiveSession, @@ -426,11 +456,15 @@ export async function runCopilotExecution(context: { workspaceOnly: effectiveFsWorkspaceOnly, }); sessionSetup.setPromptImagesCount(messageOptions.attachments?.length ?? 0); + if (!settledToolFinalization) { + await transcriptJournal.persistInitialUser(); + } if (abortRequested || params.abortSignal?.aborted) { aborted = true; externalAbort = true; } else { sentTurnStarted = true; + input.userTurnTranscriptRecorder?.markSentToProvider?.(); if (!hasNativePromptHook) { emitLlmInput(attemptInput.prompt); } @@ -438,6 +472,7 @@ export async function runCopilotExecution(context: { await bridge.awaitDeltaChain(); await bridge.awaitAgentEventChain(); const assistantCompleted = bridge.recordSendResult(result); + await transcriptJournal.barrier("sendAndWait"); settledFinalizationAssistantCompleted = settledToolFinalization && assistantCompleted; if (!assistantCompleted && !aborted) { timedOut = true; @@ -457,12 +492,30 @@ export async function runCopilotExecution(context: { await bridge?.awaitDeltaChain(); } catch {} await bridge?.awaitAgentEventChain(); + bridge?.flushTranscriptProjection(); + try { + await transcriptJournal?.barrier("timeout"); + } catch (transcriptError) { + promptError = toError(transcriptError); + } } else { - promptError = toError(error); + try { + bridge?.flushTranscriptProjection(); + await transcriptJournal?.barrier("attempt error"); + promptError = toError(error); + } catch (transcriptError) { + promptError = toError(transcriptError); + } } } } finally { settled = true; + try { + bridge?.flushTranscriptProjection(); + await transcriptJournal?.barrier("bridge detach"); + } catch (transcriptError) { + promptError = toError(transcriptError); + } userInputBridgeRef?.cancelPending(); if (activeRunHandleRef) { clearActiveEmbeddedRun( @@ -472,8 +525,14 @@ export async function runCopilotExecution(context: { input.sessionFile, ); } + const journalSnapshot = transcriptJournal?.snapshot(); + const initialUserValidated = + !sentTurnStarted || + settledToolFinalization || + journalSnapshot?.initialSdkUserValidated === true; const retainSessionForDeferredCleanup = - bridge?.hasObservedCompaction() || (timedOut && bridge?.hasObservedSessionIdle() === false); + journalSnapshot?.replayInvalid !== true && + (bridge?.hasObservedCompaction() || (timedOut && bridge?.hasObservedSessionIdle() === false)); if (retainSessionForDeferredCleanup && bridge && session && handle) { const cleanupAbort = new AbortController(); const abortCleanup = () => cleanupAbort.abort(); @@ -488,6 +547,7 @@ export async function runCopilotExecution(context: { bridge, cleanupToolBridge, cleanupByokProxy, + deleteSessionOnIncompleteCleanup: nativeSessionCreatedFresh && initialUserValidated, finalizeNativeSubagents: () => nativeSubagentTaskMirror?.finalizeActiveRuns(), handle, pool: deps.pool, @@ -556,6 +616,8 @@ export async function runCopilotExecution(context: { input, lastToolError, messages, + nativeSessionHistoryUnvalidated: !nativeSessionHistoryValidated, + transcriptJournal, modelRef, now, promptError, diff --git a/extensions/copilot/src/attempt-finalize.ts b/extensions/copilot/src/attempt-finalize.ts index 2c7811cb7489..edc4b48b9825 100644 --- a/extensions/copilot/src/attempt-finalize.ts +++ b/extensions/copilot/src/attempt-finalize.ts @@ -4,7 +4,8 @@ import { runAgentHarnessLlmOutputHook, } from "openclaw/plugin-sdk/agent-harness-runtime"; import { finalizeCopilotAttempt } from "./attempt-cleanup.js"; -import { createResult, hasMirrorIdentity, readString, readTailUserText } from "./attempt-config.js"; +import { createResult } from "./attempt-config.js"; +import type { AttemptTranscriptJournal } from "./attempt-transcript-journal.js"; import { withPromptFailure } from "./attempt-types.js"; import type { AgentHarnessAttemptResult, @@ -12,10 +13,6 @@ import type { CopilotAgentEndHookParams, ModelRef, } from "./attempt-types.js"; -import { - attachCopilotMirrorIdentity, - dualWriteCopilotTranscriptBestEffort, -} from "./dual-write-transcripts.js"; import { attachEventBridge } from "./event-bridge.js"; export async function completeCopilotAttempt(params: { aborted: boolean; @@ -32,6 +29,8 @@ export async function completeCopilotAttempt(params: { input: AttemptParamsLike; lastToolError: AgentHarnessAttemptResult["lastToolError"]; messages: AgentMessage[]; + nativeSessionHistoryUnvalidated: boolean; + transcriptJournal: AttemptTranscriptJournal | undefined; modelRef: ModelRef; now: () => number; promptError: Error | undefined; @@ -57,6 +56,8 @@ export async function completeCopilotAttempt(params: { input, lastToolError, messages, + nativeSessionHistoryUnvalidated, + transcriptJournal, modelRef, now, promptError, @@ -74,86 +75,21 @@ export async function completeCopilotAttempt(params: { const snap = bridge?.snapshot(); const assistantTexts = bridge?.finalizeAssistantTexts() ?? []; const lastAssistant = bridge?.buildAssistantMessage({ modelRef, now }); - const syntheticUserText = readString(input.transcriptPrompt) ?? readString(input.prompt); - const tailUserText = readTailUserText(messages); - const tailUserIndex = messages.findLastIndex((message) => message.role === "user"); - const currentTurnMessages = messages.map((message, index) => { - if (syntheticUserText !== tailUserText || index !== tailUserIndex) { - return message; - } - return projectAgentHarnessTranscriptMessageForDisplay({ - hidden: input.trigger === "memory", - message: attachCopilotMirrorIdentity( - { ...message, idempotencyKey: `${input.runId}:user` } as unknown as AgentMessage, - `${input.runId}:prompt`, - ), - }); - }); - const syntheticUser: AgentMessage | undefined = - syntheticUserText && syntheticUserText !== tailUserText - ? projectAgentHarnessTranscriptMessageForDisplay({ - hidden: input.trigger === "memory", - message: attachCopilotMirrorIdentity( - { - role: "user", - content: syntheticUserText, - timestamp: now(), - idempotencyKey: `${input.runId}:user`, - } as unknown as AgentMessage, - `${input.runId}:prompt`, - ), - }) - : undefined; - const taggedLastAssistant = lastAssistant - ? projectAgentHarnessTranscriptMessageForDisplay({ - hidden: input.trigger === "memory", - message: attachCopilotMirrorIdentity(lastAssistant, `${input.runId}:assistant:final`), - }) - : undefined; - const messagesSnapshot: AgentMessage[] = [ - ...currentTurnMessages, - ...(syntheticUser ? [syntheticUser] : []), - ...(taggedLastAssistant ? [taggedLastAssistant] : []), - ]; - const openClawSessionIdForMirror = readString(input.sessionId); - const sessionKeyForMirror = readString((input as { sessionKey?: unknown }).sessionKey); - const openClawStorePathForMirror = readString(input.sessionTarget?.storePath); - const mirrorScopeSessionId = sessionIdUsed ?? openClawSessionIdForMirror; - if ( - openClawSessionIdForMirror && - sessionKeyForMirror && - openClawStorePathForMirror && - messagesSnapshot.length > 0 - ) { - const taggedMessages = messagesSnapshot.map((message, index) => { - if ( - message.role !== "user" && - message.role !== "assistant" && - message.role !== "toolResult" - ) { - return message; - } - if (hasMirrorIdentity(message)) { - return message; - } - const identityScope = sdkSessionId ?? mirrorScopeSessionId ?? "attempt"; - return attachCopilotMirrorIdentity(message, `${identityScope}:${message.role}:${index}`); - }); - await dualWriteCopilotTranscriptBestEffort({ - sessionId: openClawSessionIdForMirror, - sessionKey: sessionKeyForMirror, - agentId: readString(input.agentId), - storePath: openClawStorePathForMirror, - messages: taggedMessages, - idempotencyScope: mirrorScopeSessionId ? `copilot:${mirrorScopeSessionId}` : undefined, - config: (input as { config?: unknown }).config as never, - }).catch((mirrorError: unknown) => { - console.warn( - "[copilot-attempt] dual-write transcript wrapper rejected unexpectedly", - mirrorError, - ); - }); - } + const transcript = transcriptJournal?.snapshot(); + // Pre-journal failures keep the prepared input snapshot. Reconstructing a + // user/assistant mirror here would restore the deleted dual-write owner. + const recorder = input.userTurnTranscriptRecorder; + const currentRunUserKey = `${input.runId}:user`; + const messagesSnapshot = + transcript?.messagesSnapshot ?? + (recorder?.isBlocked() + ? removePreparedUser(messages, recorder.message, currentRunUserKey) + : includePreparedUser( + messages, + recorder?.message, + input.trigger === "memory", + currentRunUserKey, + )); const result = createResult(input, { aborted, assistantTexts, @@ -170,7 +106,19 @@ export async function completeCopilotAttempt(params: { }, lastAssistant, lastToolError, + journalValidated: + transcript !== undefined && + !aborted && + !timedOut && + promptError === undefined && + !nativeSessionHistoryUnvalidated && + transcriptJournal?.hasFailed() !== true && + !transcript?.replayInvalid && + (!sentTurnStarted || settledToolFinalization || transcript.initialSdkUserValidated), messagesSnapshot, + assistantTranscriptOwned: transcript?.assistantTranscriptOwned, + assistantTranscriptIdempotencyKey: transcript?.assistantTranscriptIdempotencyKey, + nativeReplayInvalid: transcript?.replayInvalid === true || nativeSessionHistoryUnvalidated, now, promptError, resumeFailureRecovered, @@ -182,7 +130,7 @@ export async function completeCopilotAttempt(params: { usage: snap?.usage, yieldDetected, }); - if (sentTurnStarted && !settledToolFinalization) { + if (sentTurnStarted && !settledToolFinalization && !transcriptJournal?.hasFailed()) { runAgentHarnessLlmOutputHook({ event: { runId: input.runId, @@ -222,3 +170,77 @@ export async function completeCopilotAttempt(params: { ? result : finalizeCopilotAttempt(input, result, hookContext, attemptStartedAt, now); } + +function includePreparedUser( + messages: AgentMessage[], + prepared: Extract | undefined, + hidden: boolean, + currentRunUserKey: string, +): AgentMessage[] { + if (!prepared) { + return messages; + } + const projected = projectAgentHarnessTranscriptMessageForDisplay({ + hidden: hidden || (prepared as { display?: boolean }).display === false, + message: prepared, + }) as Extract; + const tail = messages.at(-1); + if (isSamePreparedUser(tail, projected, currentRunUserKey)) { + return [...messages.slice(0, -1), projected]; + } + return [...messages, projected]; +} + +function removePreparedUser( + messages: AgentMessage[], + prepared: Extract | undefined, + currentRunUserKey: string, +): AgentMessage[] { + return prepared && isSamePreparedUser(messages.at(-1), prepared, currentRunUserKey) + ? messages.slice(0, -1) + : messages; +} + +function isSamePreparedUser( + candidate: AgentMessage | undefined, + prepared: Extract, + currentRunUserKey: string, +): boolean { + if (candidate?.role !== "user") { + return false; + } + if (candidate === prepared) { + return true; + } + const candidateKey = (candidate as { idempotencyKey?: unknown }).idempotencyKey; + const preparedKey = (prepared as { idempotencyKey?: unknown }).idempotencyKey; + if (typeof candidateKey === "string" || typeof preparedKey === "string") { + if (typeof candidateKey === "string" && typeof preparedKey === "string") { + return candidateKey === preparedKey; + } + if ( + typeof candidateKey !== "string" || + typeof preparedKey === "string" || + (!candidateKey.startsWith("copilot:") && candidateKey !== currentRunUserKey) + ) { + return false; + } + } + return ( + candidate.timestamp === prepared.timestamp && + userText(candidate.content) === userText(prepared.content) + ); +} + +function userText(content: unknown): string { + if (typeof content === "string") { + return content; + } + if (Array.isArray(content) && content.length === 1) { + const part = content[0] as { text?: unknown; type?: unknown }; + if (part?.type === "text" && typeof part.text === "string") { + return part.text; + } + } + return JSON.stringify(content) ?? ""; +} diff --git a/extensions/copilot/src/attempt-transcript-journal.test.ts b/extensions/copilot/src/attempt-transcript-journal.test.ts new file mode 100644 index 000000000000..e02114143246 --- /dev/null +++ b/extensions/copilot/src/attempt-transcript-journal.test.ts @@ -0,0 +1,1263 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import type { SessionEvent } from "@github/copilot-sdk"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { + initializeGlobalHookRunner, + resetGlobalHookRunner, +} from "openclaw/plugin-sdk/hook-runtime"; +import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime"; +import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; +import { + readSessionTranscriptEvents, + type SessionTranscriptTargetParams, +} from "openclaw/plugin-sdk/session-transcript-runtime"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createAttemptTranscriptJournal } from "./attempt-transcript-journal.js"; +import type { AttemptParamsLike } from "./attempt-types.js"; +import { attachEventBridge, type SessionLike } from "./event-bridge.js"; + +const tempDirs: string[] = []; + +type FakeSession = SessionLike & { + emit: (event: SessionEvent) => void; +}; + +function createFakeSession(): FakeSession { + const listeners = new Map void>>(); + return { + abort: vi.fn(async () => undefined), + disconnect: vi.fn(async () => undefined), + emit(sessionEvent) { + for (const listener of listeners.get(sessionEvent.type) ?? []) { + listener(sessionEvent); + } + }, + on: vi.fn((eventType: string, handler: (event: SessionEvent) => void) => { + listeners.set(eventType, [...(listeners.get(eventType) ?? []), handler]); + }) as FakeSession["on"], + sendAndWait: vi.fn(async () => undefined), + sessionId: "sdk-session", + }; +} + +function event( + type: string, + id: string, + data: Record, + agentId?: string, +): SessionEvent { + return { + type, + id, + parentId: null, + timestamp: "2026-07-26T12:00:00.000Z", + data, + ...(agentId ? { agentId } : {}), + } as SessionEvent; +} + +async function createFixture(trigger?: string) { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-copilot-journal-")); + tempDirs.push(tempDir); + const target: SessionTranscriptTargetParams = { + agentId: "main", + sessionId: "session-1", + sessionKey: "agent:main:session-1", + storePath: path.join(tempDir, "sessions.json"), + }; + const userMessage: Extract = { + role: "user", + content: "inspect both files", + timestamp: 1, + }; + let blocked = false; + let persisted = false; + const recorder = { + message: userMessage, + resolveMessage: vi.fn(async () => userMessage), + markRuntimePersistencePending: vi.fn(), + markRuntimePersisted: vi.fn(() => { + persisted = true; + }), + markBlocked: vi.fn(() => { + blocked = true; + }), + hasPersisted: () => persisted, + isBlocked: () => blocked, + hasRuntimePersistencePending: () => false, + waitForRuntimePersistence: vi.fn(async () => undefined), + persistApproved: vi.fn(async () => undefined), + persistBlocked: vi.fn(async () => undefined), + persistFallback: vi.fn(async () => undefined), + } satisfies NonNullable; + const attempt = { + agentId: "main", + prompt: "inspect both files", + runId: "run-1", + sessionId: target.sessionId, + sessionKey: target.sessionKey, + sessionTarget: target, + timeoutMs: 1000, + trigger, + userTurnTranscriptRecorder: recorder, + } as unknown as AttemptParamsLike; + await upsertSessionEntry({ + agentId: "main", + entry: { sessionId: target.sessionId, updatedAt: 1 }, + sessionKey: target.sessionKey, + storePath: target.storePath, + }); + const session = createFakeSession(); + const journal = createAttemptTranscriptJournal({ + abortSession: () => session.abort(), + attempt, + messages: [], + sdkSessionId: "sdk-session", + }); + const bridge = attachEventBridge(session, { + getSdkSessionId: () => "sdk-session", + isAborted: () => false, + transcriptProjection: { + journal, + modelRef: { api: "openai-responses", id: "gpt-5", provider: "github-copilot" }, + now: () => 2, + }, + }); + return { attempt, bridge, journal, recorder, session, target, tempDir }; +} + +function transcriptMessages(events: unknown[]) { + return events.flatMap((entry) => { + if (!entry || typeof entry !== "object" || (entry as { type?: unknown }).type !== "message") { + return []; + } + const record = entry as { + id: string; + parentId: string | null; + message: AgentMessage & { display?: boolean; idempotencyKey?: string }; + }; + return [record]; + }); +} + +afterEach(async () => { + resetGlobalHookRunner(); + vi.restoreAllMocks(); + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { force: true, recursive: true }))); +}); + +describe("Copilot attempt transcript journal", () => { + it("drains work appended after a barrier starts waiting", async () => { + const { journal, session } = await createFixture(); + await journal.persistInitialUser(); + session.emit(event("user.message", "initial-user", { content: "inspect both files" })); + session.emit( + event("assistant.message", "assistant-before-barrier", { + content: "first", + messageId: "assistant-before-barrier", + }), + ); + + const waiting = journal.barrier("concurrent event"); + queueMicrotask(() => { + session.emit( + event("assistant.message", "assistant-after-barrier", { + content: "second", + messageId: "assistant-after-barrier", + }), + ); + }); + await waiting; + + expect(journal.snapshot().messagesSnapshot.map((message) => message.role)).toEqual([ + "user", + "assistant", + "assistant", + ]); + }); + + it("gives a queued tool completion one grace turn before failing the barrier", async () => { + const { journal, session } = await createFixture(); + await journal.persistInitialUser(); + session.emit(event("user.message", "initial-user", { content: "inspect both files" })); + session.emit( + event("assistant.message", "assistant-tools", { + content: "checking", + messageId: "assistant-tools", + toolRequests: [{ arguments: {}, name: "read", toolCallId: "call-1" }], + }), + ); + + const waiting = journal.barrier("queued tool result"); + queueMicrotask(() => { + session.emit( + event("tool.execution_complete", "tool-result", { + result: { content: "done" }, + success: true, + toolCallId: "call-1", + }), + ); + }); + await waiting; + + expect(journal.snapshot().messagesSnapshot.map((message) => message.role)).toEqual([ + "user", + "assistant", + "toolResult", + ]); + }); + + it("replaces the originally staged user when async resolution changes it", async () => { + const { journal, recorder } = await createFixture(); + recorder.resolveMessage.mockResolvedValue({ + role: "user", + content: "resolved user", + timestamp: 2, + }); + + await journal.persistInitialUser(); + + expect(journal.snapshot().messagesSnapshot).toMatchObject([ + { role: "user", content: "resolved user" }, + ]); + }); + + it("removes the originally staged user when its resolved replacement is blocked", async () => { + initializeGlobalHookRunner( + createMockPluginRegistry([ + { + hookName: "before_message_write", + handler: (input: unknown) => + (input as { message: AgentMessage }).message.role === "user" + ? { block: true } + : undefined, + }, + ]), + ); + const { journal, recorder } = await createFixture(); + recorder.resolveMessage.mockResolvedValue({ + role: "user", + content: "blocked resolved user", + timestamp: 2, + }); + + await journal.persistInitialUser(); + + expect(journal.snapshot()).toMatchObject({ messagesSnapshot: [], replayInvalid: true }); + expect(recorder.markBlocked).toHaveBeenCalledOnce(); + }); + + it("marks an already-blocked initial user replay-incomplete", async () => { + const { journal, recorder } = await createFixture(); + recorder.markBlocked(); + + await journal.persistInitialUser(); + + expect(journal.snapshot()).toMatchObject({ messagesSnapshot: [], replayInvalid: true }); + }); + + it("marks empty transformed user content replay-incomplete", async () => { + const { journal, session } = await createFixture(); + await journal.persistInitialUser(); + session.emit( + event("user.message", "transformed-empty-user", { + content: "provider-visible original", + transformedContent: "", + }), + ); + + await journal.barrier("empty transformed user"); + + expect(journal.snapshot().replayInvalid).toBe(true); + }); + + it("marks a mismatched initial SDK user replay-incomplete", async () => { + const { journal, session } = await createFixture(); + await journal.persistInitialUser(); + session.emit( + event("user.message", "mismatched-initial-user", { content: "provider saw different" }), + ); + await journal.barrier("mismatched initial user"); + + expect(journal.snapshot().replayInvalid).toBe(true); + }); + + it("marks non-interactive SDK user modes replay-incomplete", async () => { + const { journal, session } = await createFixture(); + await journal.persistInitialUser(); + session.emit( + event("user.message", "plan-user", { + agentMode: "plan", + content: "inspect both files", + }), + ); + await journal.barrier("plan user"); + + expect(journal.snapshot().replayInvalid).toBe(true); + }); + + it("marks durable system and developer prompts replay-incomplete", async () => { + for (const role of ["system", "developer"] as const) { + const { journal, session } = await createFixture(); + await journal.persistInitialUser(); + session.emit(event("system.message", `${role}-message`, { content: "injected", role })); + + await journal.barrier(`${role} message`); + + expect(journal.snapshot().replayInvalid).toBe(true); + } + }); + + it("marks durable skill injection replay-incomplete", async () => { + const { journal, session } = await createFixture(); + await journal.persistInitialUser(); + session.emit( + event("skill.invoked", "skill-invoked", { + content: "full injected skill content", + name: "example-skill", + path: "/skills/example/SKILL.md", + }), + ); + await journal.barrier("skill invocation"); + + expect(journal.snapshot().replayInvalid).toBe(true); + }); + + it("marks durable system notifications replay-incomplete", async () => { + const { journal, session } = await createFixture(); + await journal.persistInitialUser(); + session.emit( + event("system.notification", "system-notification", { content: "background task done" }), + ); + await journal.barrier("system notification"); + + expect(journal.snapshot().replayInvalid).toBe(true); + }); + + it("marks orphaned durable reasoning replay-incomplete at terminal flush", async () => { + const { bridge, journal, session, target } = await createFixture(); + await journal.persistInitialUser(); + session.emit( + event("assistant.reasoning", "orphaned-reasoning", { + content: "durable thinking without a message", + reasoningId: "reasoning-1", + }), + ); + bridge.flushTranscriptProjection(); + session.emit( + event("assistant.message", "next-assistant", { + content: "next turn", + messageId: "next-assistant", + }), + ); + await journal.barrier("orphaned reasoning"); + + expect(journal.snapshot().replayInvalid).toBe(true); + const assistant = transcriptMessages(await readSessionTranscriptEvents(target)).find( + (row) => row.message.role === "assistant", + )?.message; + expect(assistant).toMatchObject({ + content: [{ type: "text", text: "next turn" }], + }); + }); + + it("marks a hook-suppressed standalone assistant replay-incomplete", async () => { + initializeGlobalHookRunner( + createMockPluginRegistry([ + { + hookName: "before_message_write", + handler: (input: unknown) => + (input as { message: AgentMessage }).message.role === "assistant" + ? { block: true } + : undefined, + }, + ]), + ); + const { journal, session, target } = await createFixture(); + await journal.persistInitialUser(); + session.emit(event("user.message", "initial-user", { content: "inspect" })); + session.emit( + event("assistant.message", "blocked-assistant", { + content: "provider-visible response", + messageId: "blocked-assistant", + }), + ); + await journal.barrier("blocked assistant"); + + expect(journal.snapshot().replayInvalid).toBe(true); + expect( + transcriptMessages(await readSessionTranscriptEvents(target)).map((row) => row.message.role), + ).toEqual(["user"]); + }); + + it("marks replay incomplete when a hook rewrites provider-visible assistant content", async () => { + initializeGlobalHookRunner( + createMockPluginRegistry([ + { + hookName: "before_message_write", + handler: (input: unknown) => { + const message = (input as { message: AgentMessage }).message; + if (message.role !== "assistant") { + return undefined; + } + const first = message.content[0]; + if (first?.type === "text") { + first.text = "redacted"; + } + return { message }; + }, + }, + ]), + ); + const { journal, session } = await createFixture(); + await journal.persistInitialUser(); + session.emit(event("user.message", "initial-user", { content: "inspect" })); + session.emit( + event("assistant.message", "rewritten-content", { + content: "provider-visible response", + messageId: "rewritten-content", + }), + ); + await journal.barrier("rewritten content"); + + expect(journal.snapshot().replayInvalid).toBe(true); + }); + + it("keeps replay valid for semantically equal hook payloads with reordered keys", async () => { + initializeGlobalHookRunner( + createMockPluginRegistry([ + { + hookName: "before_message_write", + handler: (input: unknown) => { + const message = (input as { message: AgentMessage }).message; + if (message.role !== "assistant") { + return undefined; + } + return { + message: { + ...message, + content: message.content.map((part) => + part.type === "text" ? { type: "text" as const, text: part.text } : part, + ), + }, + }; + }, + }, + ]), + ); + const { journal, session } = await createFixture(); + await journal.persistInitialUser(); + session.emit(event("user.message", "initial-user", { content: "inspect both files" })); + session.emit( + event("assistant.message", "same-content", { + content: "same", + messageId: "same-content", + }), + ); + await journal.barrier("same semantic content"); + + expect(journal.snapshot().replayInvalid).toBe(false); + }); + + it("rejects structurally destructive singleton hook replacements", async () => { + for (const replacement of [ + { role: "user", content: "changed role", timestamp: 2 } as AgentMessage, + { + role: "assistant", + content: [{ type: "toolCall", id: "injected", name: "read", arguments: {} }], + provider: "github-copilot", + model: "gpt-5", + stopReason: "toolUse", + timestamp: 2, + } as AgentMessage, + ]) { + resetGlobalHookRunner(); + initializeGlobalHookRunner( + createMockPluginRegistry([ + { + hookName: "before_message_write", + handler: (input: unknown) => { + const message = (input as { message: AgentMessage }).message; + if (message.role !== "assistant") { + return undefined; + } + Object.assign(message, replacement); + return { message }; + }, + }, + ]), + ); + const { journal, session, target } = await createFixture(); + await journal.persistInitialUser(); + session.emit(event("user.message", "initial-user", { content: "inspect" })); + session.emit( + event("assistant.message", "rewritten-assistant", { + content: "provider-visible response", + messageId: "rewritten-assistant", + }), + ); + await journal.barrier("rewritten assistant"); + + expect(journal.snapshot().replayInvalid).toBe(true); + expect( + transcriptMessages(await readSessionTranscriptEvents(target)).map( + (row) => row.message.role, + ), + ).toEqual(["user"]); + } + }); + + it("commits a hidden tool turn to SQLite in assistant request order", async () => { + const { bridge, journal, recorder, session, target, tempDir } = await createFixture("memory"); + await journal.persistInitialUser(); + expect(recorder.markRuntimePersisted).toHaveBeenCalledOnce(); + + const initialUser = event("user.message", "sdk-user", { content: "inspect both files" }); + session.emit(initialUser); + session.emit( + event("assistant.usage", "prior-usage", { + apiCallId: "prior-call", + inputTokens: 50, + model: "gpt-5", + outputTokens: 40, + }), + ); + const toolAssistant = event("assistant.message", "assistant-tools", { + content: "checking", + messageId: "assistant-tools-message", + model: "gpt-5", + toolRequests: [ + { arguments: { path: "a" }, name: "read", toolCallId: "call-a" }, + { arguments: { path: "b" }, name: "read", toolCallId: "call-b" }, + ], + }); + session.emit(toolAssistant); + session.emit( + event("tool.execution_start", "start-a", { toolCallId: "call-a", toolName: "read" }), + ); + session.emit( + event("tool.execution_start", "start-b", { toolCallId: "call-b", toolName: "read" }), + ); + session.emit( + event("tool.execution_complete", "result-b", { + result: { content: "B", detailedContent: "details B" }, + success: true, + toolCallId: "call-b", + }), + ); + session.emit( + event("user.message", "steering-user", { + content: "steer after tools", + delivery: "steering", + source: "future-steering-source", + }), + ); + session.emit( + event( + "tool.execution_complete", + "result-child", + { + result: { content: "child" }, + success: true, + toolCallId: "child-call", + }, + "child-1", + ), + ); + session.emit({ + ...event("tool.execution_complete", "ephemeral-result-a", { + result: { content: "transient" }, + success: true, + toolCallId: "call-a", + }), + ephemeral: true, + } as SessionEvent); + session.emit( + event("tool.execution_complete", "result-a", { + error: { message: "A failed" }, + success: false, + toolCallId: "call-a", + }), + ); + const finalAssistant = event("assistant.message", "assistant-final", { + content: "finished", + messageId: "assistant-final-message", + model: "gpt-5", + }); + session.emit(finalAssistant); + bridge.recordSendResult(finalAssistant); + session.emit(event("session.idle", "idle", {})); + await journal.barrier("test"); + + const rows = transcriptMessages(await readSessionTranscriptEvents(target)); + expect(rows.map((row) => row.message.role)).toEqual([ + "user", + "assistant", + "toolResult", + "toolResult", + "user", + "assistant", + ]); + expect(rows.map((row) => row.id).slice(1)).toEqual([ + "assistant-tools", + "result-a", + "result-b", + "steering-user", + "assistant-final", + ]); + expect(rows.map((row) => row.parentId).slice(1)).toEqual( + rows.map((row) => row.id).slice(0, -1), + ); + expect(rows.every((row) => row.message.display === false)).toBe(true); + expect(rows[0]?.message.idempotencyKey).toBe("run-1:user"); + expect(rows[1]?.message).toMatchObject({ usage: { input: 0, output: 0 } }); + expect(rows[5]?.message).toMatchObject({ + content: [{ type: "text", text: "finished" }], + }); + expect(rows[5]?.message.idempotencyKey).toBe("copilot-sdk:sdk-session:assistant-final"); + expect(rows[2]?.message).toMatchObject({ + isError: true, + toolCallId: "call-a", + content: [{ type: "text", text: "A failed" }], + }); + expect(journal.snapshot()).toMatchObject({ + assistantTranscriptOwned: true, + assistantTranscriptIdempotencyKey: "copilot-sdk:sdk-session:assistant-final", + replayInvalid: false, + }); + expect(journal.snapshot().messagesSnapshot.map((message) => message.role)).toEqual( + rows.map((row) => row.message.role), + ); + const files = await fs.readdir(tempDir, { recursive: true }); + expect(files.some((file) => file.endsWith(".jsonl"))).toBe(false); + }); + + it("groups assistant chunks from one API call before matching tool results", async () => { + const { bridge, journal, session, target } = await createFixture(); + await journal.persistInitialUser(); + session.emit(event("user.message", "initial-user", { content: "inspect both files" })); + session.emit( + event("assistant.message", "assistant-chunk-a", { + apiCallId: "api-call-1", + content: "checking ", + messageId: "assistant-chunk-a", + reasoningText: "partial reasoning", + toolRequests: [{ arguments: { path: "a" }, name: "read", toolCallId: "call-a" }], + }), + ); + session.emit( + event("assistant.message", "assistant-chunk-b", { + apiCallId: "api-call-1", + content: "now", + messageId: "assistant-chunk-b", + reasoningText: "complete reasoning", + }), + ); + session.emit( + event("tool.execution_complete", "result-a", { + result: { content: "done" }, + success: true, + toolCallId: "call-a", + }), + ); + await journal.barrier("grouped API call"); + + const rows = transcriptMessages(await readSessionTranscriptEvents(target)); + expect(rows.map((row) => row.message.role)).toEqual(["user", "assistant", "toolResult"]); + expect(rows[1]?.message).toMatchObject({ + content: [ + { type: "thinking", thinking: "complete reasoning" }, + { type: "text", text: "checking now" }, + { type: "toolCall", id: "call-a", name: "read", arguments: { path: "a" } }, + ], + idempotencyKey: "copilot-sdk:sdk-session:assistant-chunk-a", + stopReason: "toolUse", + }); + expect(rows[2]?.message).toMatchObject({ toolCallId: "call-a", toolName: "read" }); + expect( + bridge.buildAssistantMessage({ + modelRef: { api: "openai-responses", id: "gpt-5", provider: "github-copilot" }, + now: () => 3, + })?.content, + ).toEqual([ + { type: "thinking", thinking: "complete reasoning" }, + { type: "text", text: "checking now" }, + { type: "toolCall", id: "call-a", name: "read", arguments: { path: "a" } }, + ]); + expect(journal.snapshot().replayInvalid).toBe(false); + }); + + it("keeps the latest cumulative snapshot for one assistant message id", async () => { + const { bridge, journal, session, target } = await createFixture(); + await journal.persistInitialUser(); + session.emit(event("user.message", "initial-user", { content: "inspect" })); + session.emit( + event("assistant.message", "assistant-snapshot-a", { + apiCallId: "api-call-snapshot", + content: "checking", + messageId: "assistant-snapshot", + }), + ); + session.emit( + event("assistant.message", "assistant-snapshot-b", { + apiCallId: "api-call-snapshot", + content: "checking now", + messageId: "assistant-snapshot", + }), + ); + bridge.flushTranscriptProjection(); + await journal.barrier("cumulative assistant snapshot"); + + const rows = transcriptMessages(await readSessionTranscriptEvents(target)); + expect(rows.map((row) => row.message.role)).toEqual(["user", "assistant"]); + expect(rows.filter((row) => row.message.role === "assistant")).toHaveLength(1); + expect(rows.at(-1)?.message).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: "checking now" }], + }); + }); + + it("rejects cumulative assistant snapshots without an API call id", async () => { + const { bridge, journal, session, target } = await createFixture(); + await journal.persistInitialUser(); + session.emit(event("user.message", "initial-user", { content: "inspect" })); + session.emit( + event("assistant.message", "assistant-snapshot-a", { + content: "checking", + messageId: "assistant-snapshot", + }), + ); + session.emit( + event("assistant.message", "assistant-snapshot-b", { + content: "checking now", + messageId: "assistant-snapshot", + }), + ); + bridge.flushTranscriptProjection(); + await journal.barrier("cumulative assistant snapshot without API call id"); + + const rows = transcriptMessages(await readSessionTranscriptEvents(target)); + expect(rows.map((row) => row.message.role)).toEqual(["user", "assistant"]); + expect(rows.at(-1)?.message).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: "checking" }], + }); + expect( + bridge.buildAssistantMessage({ + modelRef: { api: "openai-responses", id: "gpt-5", provider: "github-copilot" }, + now: () => 3, + })?.content, + ).toEqual([{ type: "text", text: "checking now" }]); + expect(journal.snapshot().replayInvalid).toBe(true); + }); + + it("keeps ephemeral deltas out of the durable assistant row", async () => { + const { journal, session, target } = await createFixture(); + await journal.persistInitialUser(); + session.emit(event("user.message", "initial-user", { content: "inspect both files" })); + session.emit({ + ...event("assistant.message_delta", "ephemeral-text", { + deltaContent: "stream-only text", + messageId: "stream-only", + }), + ephemeral: true, + } as SessionEvent); + session.emit({ + ...event("assistant.reasoning_delta", "ephemeral-reasoning", { + deltaContent: "stream-only reasoning", + reasoningId: "stream-reasoning", + }), + ephemeral: true, + } as SessionEvent); + session.emit( + event("assistant.message", "durable-assistant", { + content: "visible", + messageId: "durable-assistant", + }), + ); + await journal.barrier("ephemeral deltas"); + + const assistant = transcriptMessages(await readSessionTranscriptEvents(target)).find( + (row) => row.message.role === "assistant", + )?.message; + expect(assistant).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: "visible" }], + }); + }); + + it("clears prior assistant ownership when the final durable projection is empty", async () => { + const { bridge, journal, session } = await createFixture(); + await journal.persistInitialUser(); + session.emit(event("user.message", "initial-user", { content: "inspect both files" })); + session.emit( + event("assistant.message", "tool-assistant", { + content: "checking", + messageId: "tool-assistant", + toolRequests: [{ arguments: {}, name: "read", toolCallId: "call-1" }], + }), + ); + session.emit( + event("tool.execution_complete", "tool-result", { + result: { content: "done" }, + success: true, + toolCallId: "call-1", + }), + ); + session.emit({ + ...event("assistant.message_delta", "final-delta", { + deltaContent: "streamed final", + messageId: "final-message", + }), + ephemeral: true, + } as SessionEvent); + session.emit( + event("assistant.message", "final-empty", { + content: "", + messageId: "final-message", + }), + ); + bridge.flushTranscriptProjection(); + await journal.barrier("empty final projection"); + + expect(journal.snapshot()).toMatchObject({ + assistantTranscriptOwned: false, + replayInvalid: true, + }); + expect( + bridge.buildAssistantMessage({ + modelRef: { api: "openai-responses", id: "gpt-5", provider: "github-copilot" }, + now: () => 3, + })?.content, + ).toEqual([{ type: "text", text: "streamed final" }]); + }); + + it("marks unprojected assistant provider round-trip state replay-incomplete", async () => { + const { journal, session } = await createFixture(); + await journal.persistInitialUser(); + session.emit(event("user.message", "initial-user", { content: "inspect both files" })); + session.emit( + event("assistant.message", "assistant-provider-state", { + apiCallId: "api-call-provider-state", + content: "", + messageId: "assistant-provider-state", + reasoningWireField: "reasoning_content", + serverTools: { provider: "openai", items: [{ type: "web_search" }] }, + }), + ); + session.emit( + event("assistant.usage", "assistant-provider-usage", { + apiCallId: "api-call-provider-state", + model: "gpt-5", + outputTokens: 1, + }), + ); + await journal.barrier("provider round-trip state"); + + expect(journal.snapshot().replayInvalid).toBe(true); + expect(journal.snapshot().messagesSnapshot).toMatchObject([{ role: "user" }]); + }); + + it("marks citation-bearing assistant messages replay-incomplete", async () => { + const { journal, session } = await createFixture(); + await journal.persistInitialUser(); + session.emit(event("user.message", "initial-user", { content: "inspect both files" })); + session.emit( + event("assistant.message", "assistant-citations", { + content: "cited response", + messageId: "assistant-citations", + citations: { + sources: [ + { + id: "source-1", + provider: "openai", + title: "Example source", + url: "https://example.com/source", + }, + ], + spans: [ + { + endIndex: 5, + references: [{ sourceId: "source-1" }], + startIndex: 0, + }, + ], + }, + }), + ); + await journal.barrier("citation-bearing assistant"); + + expect(journal.snapshot()).toMatchObject({ + messagesSnapshot: [{ role: "user" }, { role: "assistant" }], + replayInvalid: true, + }); + }); + + it("marks session-bound encrypted reasoning replay-incomplete", async () => { + for (const [field, value] of [ + ["encryptedContent", "encrypted-openai-state"], + ["reasoningOpaque", "opaque-anthropic-state"], + ] as const) { + const { journal, session } = await createFixture(); + await journal.persistInitialUser(); + session.emit(event("user.message", `initial-user-${field}`, { content: "inspect" })); + session.emit( + event("assistant.message", `assistant-${field}`, { + apiCallId: `api-call-${field}`, + content: "", + messageId: `assistant-${field}`, + [field]: value, + }), + ); + session.emit( + event("assistant.usage", `assistant-usage-${field}`, { + apiCallId: `api-call-${field}`, + model: "gpt-5", + }), + ); + await journal.barrier(`session-bound ${field}`); + + expect(journal.snapshot().replayInvalid).toBe(true); + } + }); + + it("marks custom tool calls replay-incomplete", async () => { + const { journal, session } = await createFixture(); + await journal.persistInitialUser(); + session.emit(event("user.message", "initial-user", { content: "inspect" })); + session.emit( + event("assistant.message", "assistant-custom-tool", { + content: "run custom tool", + messageId: "assistant-custom-tool", + toolRequests: [ + { + arguments: { input: "value" }, + name: "custom_tool", + toolCallId: "custom-call", + type: "custom", + }, + ], + }), + ); + session.emit( + event("tool.execution_complete", "custom-result", { + result: { content: "done" }, + success: true, + toolCallId: "custom-call", + }), + ); + await journal.barrier("custom tool group"); + + expect(journal.snapshot().replayInvalid).toBe(true); + }); + + it("marks user-requested tools replay-incomplete", async () => { + const { journal, session } = await createFixture(); + await journal.persistInitialUser(); + session.emit( + event("tool.user_requested", "user-tool-request", { + arguments: { path: "a" }, + toolCallId: "user-call", + toolName: "read", + }), + ); + session.emit( + event("tool.execution_complete", "user-tool-result", { + result: { content: "done" }, + success: true, + toolCallId: "user-call", + }), + ); + await journal.barrier("user-requested tool"); + + expect(journal.snapshot().replayInvalid).toBe(true); + }); + + it("preserves user-requested identity across an ephemeral completion", async () => { + const { journal, session, target } = await createFixture(); + await journal.persistInitialUser(); + session.emit( + event("tool.user_requested", "user-tool-request", { + arguments: { path: "a" }, + toolCallId: "user-call", + toolName: "read", + }), + ); + session.emit({ + ...event("tool.execution_complete", "ephemeral-user-tool-result", { + result: { content: "partial" }, + success: true, + toolCallId: "user-call", + }), + ephemeral: true, + } as SessionEvent); + session.emit( + event("tool.execution_complete", "durable-user-tool-result", { + result: { content: "done" }, + success: true, + toolCallId: "user-call", + }), + ); + await journal.barrier("user-requested completion"); + + expect(transcriptMessages(await readSessionTranscriptEvents(target))).toHaveLength(1); + expect(journal.snapshot()).toMatchObject({ replayInvalid: true }); + }); + + it("hides autopilot users while preserving unknown SDK source provenance", async () => { + initializeGlobalHookRunner( + createMockPluginRegistry([ + { + hookName: "before_message_write", + handler: (input: unknown) => { + const replacement = { ...(input as { message: AgentMessage }).message }; + delete (replacement as { display?: boolean }).display; + return { message: replacement }; + }, + }, + ]), + ); + const { journal, session, target } = await createFixture(); + await journal.persistInitialUser(); + session.emit(event("user.message", "initial-user", { content: "inspect both files" })); + session.emit( + event("user.message", "autopilot-user", { + content: "continue", + attachments: [ + { + type: "file", + displayName: "notes.txt", + mimeType: "text/plain", + path: "/tmp/notes.txt", + }, + { + type: "blob", + data: "c2VjcmV0LWJ5dGVz", + displayName: "image.png", + mimeType: "image/png", + }, + ], + isAutopilotContinuation: true, + source: "future-source-kind", + }), + ); + session.emit( + event("tool.execution_complete", "user-tool-result", { + isUserRequested: true, + result: { content: "user tool result" }, + success: true, + toolCallId: "user-call", + }), + ); + session.emit( + event("user.message", "skill-user", { + content: "injected skill context", + source: "skill-pdf", + }), + ); + session.emit( + event("user.message", "unknown-user", { + content: "unknown source context", + source: "future-visible-source", + }), + ); + await journal.barrier("test"); + + const rows = transcriptMessages(await readSessionTranscriptEvents(target)); + expect(rows).toHaveLength(4); + expect(rows[1]?.message).toMatchObject({ + role: "user", + content: "continue", + display: false, + __openclaw: { + copilotSource: "future-source-kind", + media: [{ path: "/tmp/notes.txt", contentType: "text/plain" }], + copilotAttachments: [ + expect.objectContaining({ type: "file", path: "/tmp/notes.txt" }), + expect.not.objectContaining({ data: expect.anything() }), + ], + }, + }); + expect(rows[2]?.message).toMatchObject({ display: false }); + expect(rows[3]?.message).not.toHaveProperty("display", false); + expect(rows[3]?.message).toMatchObject({ + __openclaw: { copilotSource: "future-visible-source" }, + }); + expect(journal.snapshot().replayInvalid).toBe(true); + }); + + it("keeps a complete-group prefix when the next group is interrupted", async () => { + const { journal, session, target } = await createFixture(); + await journal.persistInitialUser(); + session.emit(event("user.message", "initial-user", { content: "inspect both files" })); + session.emit( + event("assistant.message", "assistant-complete", { + content: "first", + messageId: "assistant-complete", + toolRequests: [{ arguments: {}, name: "read", toolCallId: "call-complete" }], + }), + ); + session.emit( + event("tool.execution_complete", "result-complete", { + result: { content: "done" }, + success: true, + toolCallId: "call-complete", + }), + ); + await journal.barrier("complete group"); + session.emit( + event("assistant.message", "assistant-open", { + content: "second", + messageId: "assistant-open", + toolRequests: [{ arguments: {}, name: "read", toolCallId: "call-open" }], + }), + ); + + await expect(journal.barrier("abort")).rejects.toMatchObject({ + code: "transcript_persistence_failed", + }); + const rows = transcriptMessages(await readSessionTranscriptEvents(target)); + expect(rows.map((row) => row.message.role)).toEqual(["user", "assistant", "toolResult"]); + expect(rows.filter((row) => row.message.role === "assistant")).toHaveLength(1); + expect(rows.filter((row) => row.message.role === "toolResult")).toHaveLength(1); + }); + + it("suppresses the complete group when one message is authoritatively blocked", async () => { + initializeGlobalHookRunner( + createMockPluginRegistry([ + { + hookName: "before_message_write", + handler: (input: unknown) => + (input as { message: AgentMessage }).message.role === "toolResult" + ? { block: true } + : undefined, + }, + ]), + ); + const { journal, session, target } = await createFixture(); + await journal.persistInitialUser(); + session.emit(event("user.message", "initial-user", { content: "inspect both files" })); + session.emit( + event("assistant.message", "assistant-blocked", { + content: "checking", + messageId: "assistant-blocked", + toolRequests: [{ arguments: {}, name: "read", toolCallId: "call-blocked" }], + }), + ); + session.emit( + event("tool.execution_complete", "result-blocked", { + result: { content: "secret" }, + success: true, + toolCallId: "call-blocked", + }), + ); + await journal.barrier("blocked group"); + + const rows = transcriptMessages(await readSessionTranscriptEvents(target)); + expect(rows.map((row) => row.message.role)).toEqual(["user"]); + expect(journal.snapshot()).toMatchObject({ + assistantTranscriptOwned: true, + replayInvalid: true, + }); + }); + + it("does not rerun group hooks for an idempotent replay", async () => { + const hook = vi.fn(() => undefined); + initializeGlobalHookRunner( + createMockPluginRegistry([{ hookName: "before_message_write", handler: hook }]), + ); + const { attempt, journal, session, target } = await createFixture(); + const emitGroup = (targetSession: FakeSession) => { + targetSession.emit(event("user.message", "initial-user", { content: "inspect both files" })); + targetSession.emit( + event("assistant.message", "assistant-replay", { + content: "checking", + messageId: "assistant-replay", + toolRequests: [{ arguments: {}, name: "read", toolCallId: "call-replay" }], + }), + ); + targetSession.emit( + event("tool.execution_complete", "result-replay", { + result: { content: "done" }, + success: true, + toolCallId: "call-replay", + }), + ); + }; + await journal.persistInitialUser(); + emitGroup(session); + await journal.barrier("first commit"); + expect(hook).toHaveBeenCalledTimes(3); + const existingMessages = transcriptMessages(await readSessionTranscriptEvents(target)).map( + (row) => row.message, + ); + + const replaySession = createFakeSession(); + const replayJournal = createAttemptTranscriptJournal({ + abortSession: () => replaySession.abort(), + attempt, + messages: existingMessages, + sdkSessionId: "sdk-session", + }); + attachEventBridge(replaySession, { + getSdkSessionId: () => "sdk-session", + isAborted: () => false, + transcriptProjection: { + journal: replayJournal, + modelRef: { api: "openai-responses", id: "gpt-5", provider: "github-copilot" }, + now: () => 2, + }, + }); + await replayJournal.persistInitialUser(); + emitGroup(replaySession); + await replayJournal.barrier("replay"); + + expect(hook).toHaveBeenCalledTimes(3); + expect(transcriptMessages(await readSessionTranscriptEvents(target))).toHaveLength(3); + expect(replayJournal.snapshot().messagesSnapshot).toHaveLength(3); + }); + + it("rolls back the complete group when SQLite fails mid-group", async () => { + const { journal, session, target, tempDir } = await createFixture(); + await journal.persistInitialUser(); + session.emit(event("user.message", "initial-user", { content: "inspect both files" })); + const sqliteName = (await fs.readdir(tempDir, { recursive: true })).find((name) => + name.endsWith(".sqlite"), + ); + if (!sqliteName) { + throw new Error("expected the real SQLite transcript database"); + } + const database = new DatabaseSync(path.join(tempDir, sqliteName)); + database.exec(` + CREATE TRIGGER fail_copilot_tool_result + BEFORE INSERT ON transcript_events + WHEN NEW.event_json LIKE '%result-failed%' + BEGIN + SELECT RAISE(ABORT, 'injected mid-group failure'); + END; + `); + database.close(); + session.emit( + event("assistant.message", "assistant-failed", { + content: "checking", + messageId: "assistant-failed", + toolRequests: [{ arguments: {}, name: "read", toolCallId: "call-failed" }], + }), + ); + session.emit( + event("tool.execution_complete", "result-failed", { + result: { content: "never committed" }, + success: true, + toolCallId: "call-failed", + }), + ); + + await expect(journal.barrier("failed group")).rejects.toThrow("injected mid-group failure"); + expect( + transcriptMessages(await readSessionTranscriptEvents(target)).map((row) => row.message.role), + ).toEqual(["user"]); + }); +}); diff --git a/extensions/copilot/src/attempt-transcript-journal.ts b/extensions/copilot/src/attempt-transcript-journal.ts new file mode 100644 index 000000000000..9dea33ac0da1 --- /dev/null +++ b/extensions/copilot/src/attempt-transcript-journal.ts @@ -0,0 +1,620 @@ +import { isDeepStrictEqual } from "node:util"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { + projectAgentHarnessTranscriptMessageForDisplay, + runAgentHarnessBeforeMessageWriteHook, +} from "openclaw/plugin-sdk/agent-harness-runtime"; +import { + appendSessionTranscriptMessageByIdentityStrict, + appendSessionTranscriptMessagesByIdentity, + publishSessionTranscriptUpdateByIdentity, + readVisibleSessionTranscriptMessageEntries, + type SessionTranscriptTargetParams, +} from "openclaw/plugin-sdk/session-transcript-runtime"; +import type { AttemptParamsLike } from "./attempt-types.js"; + +type TranscriptMessage = Extract; +type AppendResult = + | { appended: boolean; message: TranscriptMessage; messageId: string } + | undefined; +type PendingWrite = { eventId?: string; message: TranscriptMessage }; +type ToolGroup = { + assistant: PendingWrite; + assistantKey: string; + order: string[]; + results: Map; +}; + +export type AttemptTranscriptJournal = ReturnType; + +export function createAttemptTranscriptJournal(params: { + abortSession: () => Promise; + attempt: AttemptParamsLike; + messages: AgentMessage[]; + sdkSessionId: string; +}) { + const hiddenTurn = params.attempt.trigger === "memory"; + const projectDisplay = (message: AgentMessage) => + projectAgentHarnessTranscriptMessageForDisplay({ + hidden: hiddenTurn || (message as { display?: boolean }).display === false, + message, + }); + const messagesSnapshot = [...params.messages]; + const snapshotIdempotencyKeys = new Set( + messagesSnapshot.flatMap((message) => { + const key = readIdempotencyKey(message); + return key && isCurrentJournalIdentity(key, params) ? [key] : []; + }), + ); + const replaceTailUser = ( + current: Extract | undefined, + next?: AgentMessage, + ) => { + if (isSameUserTurn(messagesSnapshot.at(-1), current, `${params.attempt.runId}:user`)) { + const removed = messagesSnapshot.pop(); + const removedKey = removed ? readIdempotencyKey(removed) : undefined; + if (removedKey && isCurrentJournalIdentity(removedKey, params)) { + snapshotIdempotencyKeys.delete(removedKey); + } + } + if (next) { + messagesSnapshot.push(next); + const nextKey = readIdempotencyKey(next); + if (nextKey && isCurrentJournalIdentity(nextKey, params)) { + snapshotIdempotencyKeys.add(nextKey); + } + } + }; + // The host recorder owns prompt construction. Missing recorders fail closed + // before dispatch below; the journal never reconstructs a prompt string. + const currentUser = params.attempt.userTurnTranscriptRecorder?.message; + if (currentUser) { + replaceTailUser(currentUser, projectDisplay(currentUser)); + } + const target = resolveTranscriptTarget(params.attempt); + const config = params.attempt.config; + const seenEventIds = new Set(); + const deferredUserWrites: PendingWrite[] = []; + let pendingTools: ToolGroup | undefined; + let queue = Promise.resolve(); + let firstFailure: Error | undefined; + let abortPromise: Promise | undefined; + let replayInvalid = false; + let initialSdkUserObserved = false; + let initialSdkUserValidated = false; + let persistedInitialUser: Extract | undefined; + let latestAssistantKey: string | undefined; + let assistantTranscriptOwned = false; + let assistantTranscriptIdempotencyKey: string | undefined; + + const captureFailure = (error: unknown) => { + if (firstFailure) { + return; + } + firstFailure = error instanceof Error ? error : new Error(String(error)); + replayInvalid = true; + pendingTools = undefined; + abortPromise = params.abortSession().catch(() => undefined); + }; + const claim = (eventId: string) => + !firstFailure && !seenEventIds.has(eventId) && Boolean(seenEventIds.add(eventId)); + + const schedule = (task: () => Promise | void) => { + if (firstFailure) { + return; + } + // The SDK checkpoint can advance before this queue. SQLite closes the window inside a + // complete group; a crash between groups leaves a structurally valid prefix. + queue = queue.then(() => (firstFailure ? undefined : task())).catch(captureFailure); + }; + + const prepare = ( + write: PendingWrite, + options: { singleton?: boolean } = {}, + ): TranscriptMessage | undefined => { + const message = structuredClone(write.message) as TranscriptMessage; + const originalReplayPayload = structuredClone(projectReplayPayload(message)); + const hooked = runAgentHarnessBeforeMessageWriteHook({ + message: structuredClone(message) as TranscriptMessage, + agentId: target.agentId, + sessionKey: target.sessionKey, + }); + if (!hooked) { + return undefined; + } + if ( + !isDeepStrictEqual(originalReplayPayload, projectReplayPayload(hooked as TranscriptMessage)) + ) { + replayInvalid = true; + } + const idempotencyKey = (message as { idempotencyKey?: string }).idempotencyKey; + const toolIdentity = + message.role === "toolResult" + ? { toolCallId: message.toolCallId, toolName: message.toolName } + : {}; + const prepared = projectDisplay({ + ...hooked, + ...toolIdentity, + ...(idempotencyKey ? { idempotencyKey } : {}), + ...((message as { display?: boolean }).display === false ? { display: false } : {}), + }) as TranscriptMessage; + return options.singleton && !isCompatibleSingletonRewrite(message, prepared) + ? undefined + : prepared; + }; + + const append = async (write: PendingWrite): Promise => { + const outcome = await appendSessionTranscriptMessageByIdentityStrict({ + ...target, + ...(config ? { config } : {}), + ...(write.eventId ? { eventId: write.eventId } : {}), + idempotencyLookup: "scan", + message: write.message, + prepareMessageAfterIdempotencyCheck: () => prepare(write, { singleton: true }), + }); + if (outcome.kind === "suppressed") { + return undefined; + } + if (outcome.kind === "rejected") { + throw new Error("Transcript session changed before singleton append"); + } + if ( + !isDeepStrictEqual( + projectReplayPayload(write.message), + projectReplayPayload(outcome.result.message as TranscriptMessage), + ) + ) { + replayInvalid = true; + } + return outcome.result as AppendResult; + }; + + const appendToolGroup = async (group: ToolGroup) => { + const writes = [group.assistant, ...group.order.map((id) => group.results.get(id)!)]; + const keys = writes.map((write) => readIdempotencyKey(write.message)); + const persistedKeys = new Set( + (await readVisibleSessionTranscriptMessageEntries(target)).flatMap((entry) => + entry.idempotencyKey ? [entry.idempotencyKey] : [], + ), + ); + const persistedCount = keys.filter((key) => key && persistedKeys.has(key)).length; + if (persistedCount > 0 && persistedCount < writes.length) { + // The pre-atomic journal was never shipped. Partial identity is corruption, + // not a runtime compatibility shape; recovery stays fail-closed. + throw new Error("Copilot transcript found a partial persisted tool group"); + } + // Hooks must finish before BEGIN. This skips steady-state replay hooks; the + // transaction still revalidates all identities against cross-process races. + const messages = + persistedCount === writes.length + ? writes.map((write) => write.message) + : writes.map((write) => prepare(write)); + // Hook omission belongs to policy, but the journal owns structure: one block or + // structurally destructive rewrite suppresses the complete assistant/result group. + if ( + messages.some((message) => !message) || + !isCompleteToolGroup(messages as TranscriptMessage[], group.order) + ) { + return undefined; + } + const results = await appendSessionTranscriptMessagesByIdentity({ + ...target, + ...(config ? { config } : {}), + messages: writes.map((write, index) => ({ + eventId: write.eventId!, + idempotencyLookup: "scan" as const, + message: messages[index]!, + })), + }); + if ( + !isCompleteToolGroup( + results.map((result) => result.message), + group.order, + ) + ) { + throw new Error("Copilot transcript replayed an invalid tool group"); + } + if ( + results.some( + (result, index) => + !isDeepStrictEqual( + projectReplayPayload(writes[index]!.message), + projectReplayPayload(result.message as TranscriptMessage), + ), + ) + ) { + replayInvalid = true; + } + return results; + }; + + const publish = async (appended: boolean) => { + if (appended) { + await publishSessionTranscriptUpdateByIdentity({ ...target }).catch((error: unknown) => { + console.warn("[copilot-attempt] transcript update notification failed", error); + }); + } + }; + + const accept = (result: AppendResult): boolean => { + if (!result) { + return false; + } + const key = readIdempotencyKey(result.message); + const snapshotKey = key && isCurrentJournalIdentity(key, params) ? key : undefined; + if (!snapshotKey || !snapshotIdempotencyKeys.has(snapshotKey)) { + messagesSnapshot.push(result.message); + if (snapshotKey) { + snapshotIdempotencyKeys.add(snapshotKey); + } + } + return result.appended; + }; + const ownAssistant = (key: string, persisted: boolean) => { + if (latestAssistantKey === key) { + assistantTranscriptOwned = true; + assistantTranscriptIdempotencyKey = persisted ? key : undefined; + } + }; + + const drainQueue = async () => { + // SDK events can append work while an earlier write is pending. Drain until + // the observed tail stays stable so finalization cannot outrun persistence. + while (true) { + const tail = queue; + await tail; + if (tail === queue) { + break; + } + } + }; + + const barrier = async (boundary: string) => { + await drainQueue(); + if (!firstFailure) { + // Give an already-delivered SDK event one microtask turn to reach the + // bridge, then re-drain without another yield before returning. + await Promise.resolve(); + await drainQueue(); + } + if (!firstFailure && pendingTools) { + captureFailure( + new Error( + `Copilot transcript reached ${boundary} with unresolved tool results: ${pendingTools.order.join(", ")}`, + ), + ); + } + if (abortPromise) { + await abortPromise; + } + if (firstFailure) { + const error = new Error( + `[copilot-attempt] canonical transcript persistence failed: ${firstFailure.message}`, + { cause: firstFailure }, + ) as Error & { code?: string }; + error.code = "transcript_persistence_failed"; + throw error; + } + }; + + return { + markReplayIncomplete() { + replayInvalid = true; + }, + recordAssistantProjectionGap() { + replayInvalid = true; + latestAssistantKey = undefined; + assistantTranscriptOwned = false; + assistantTranscriptIdempotencyKey = undefined; + }, + async persistInitialUser() { + const recorder = params.attempt.userTurnTranscriptRecorder; + if (!recorder) { + captureFailure(new Error("Copilot transcript requires a user-turn recorder")); + return await barrier("user prompt"); + } + if (recorder.isBlocked()) { + replayInvalid = true; + replaceTailUser(recorder.message); + return; + } + const persistence = (async () => { + const resolved = await recorder.resolveMessage(); + if (!resolved) { + throw new Error("Copilot transcript user turn resolved without a message"); + } + const outcome = await append({ + message: { + ...resolved, + idempotencyKey: `${params.attempt.runId}:user`, + } as TranscriptMessage, + }); + replaceTailUser(currentUser); + if (!outcome) { + replayInvalid = true; + recorder.markBlocked(); + return; + } + const persisted = outcome.message as Extract; + accept(outcome); + persistedInitialUser = persisted; + recorder.markRuntimePersisted(persisted); + params.attempt.onUserMessagePersisted?.(persisted); + await publish(outcome.appended); + })(); + recorder.markRuntimePersistencePending(persistence); + await persistence.catch(captureFailure); + await barrier("user prompt"); + }, + recordSdkUser(input: { + eventId: string; + message: Extract; + autopilotContinuation: boolean; + replayIncomplete?: boolean; + }) { + if (!claim(input.eventId)) { + return; + } + replayInvalid ||= input.replayIncomplete === true; + if (!initialSdkUserObserved && !input.autopilotContinuation) { + initialSdkUserObserved = true; + if ( + !persistedInitialUser || + userText(persistedInitialUser.content) !== userText(input.message.content) + ) { + replayInvalid = true; + } else { + initialSdkUserValidated = true; + } + return; + } + initialSdkUserObserved = true; + schedule(async () => { + const write = { eventId: input.eventId, message: input.message }; + if (pendingTools) { + deferredUserWrites.push(write); + return; + } + const outcome = await append(write); + if (!outcome) { + replayInvalid = true; + } + await publish(accept(outcome)); + }); + }, + recordAssistant(input: { + eventId: string; + message: Extract; + replayIncomplete?: boolean; + toolCallIds: string[]; + }) { + if (!claim(input.eventId)) { + return; + } + replayInvalid ||= input.replayIncomplete === true; + const key = `copilot-sdk:${params.sdkSessionId}:${input.eventId}`; + latestAssistantKey = key; + assistantTranscriptOwned = false; + assistantTranscriptIdempotencyKey = undefined; + schedule(async () => { + if (pendingTools) { + throw new Error("Copilot emitted an assistant message before tool results settled"); + } + const write = { + eventId: input.eventId, + message: { ...input.message, idempotencyKey: key } as TranscriptMessage, + }; + if (input.toolCallIds.length > 0) { + pendingTools = { + assistant: write, + assistantKey: key, + order: input.toolCallIds, + results: new Map(), + }; + return; + } + const outcome = await append(write); + if (!outcome) { + replayInvalid = true; + } + ownAssistant(key, Boolean(outcome)); + await publish(accept(outcome)); + }); + }, + recordToolResult(input: { + eventId: string; + message: Extract; + replayIncomplete?: boolean; + }) { + if (!claim(input.eventId)) { + return; + } + schedule(async () => { + const group = pendingTools; + if (!group || !group.order.includes(input.message.toolCallId)) { + throw new Error(`Copilot emitted an unmatched tool result: ${input.message.toolCallId}`); + } + group.results.set(input.message.toolCallId, { + eventId: input.eventId, + message: { + ...input.message, + idempotencyKey: `copilot-sdk:${params.sdkSessionId}:${input.eventId}`, + } as TranscriptMessage, + }); + replayInvalid ||= input.replayIncomplete === true; + if (!group.order.every((toolCallId) => group.results.has(toolCallId))) { + return; + } + const results = await appendToolGroup(group); + let appended = false; + if (!results) { + replayInvalid = true; + ownAssistant(group.assistantKey, false); + } else { + for (const result of results) { + const didAppend = accept(result as AppendResult); + appended ||= didAppend; + } + ownAssistant(group.assistantKey, true); + } + pendingTools = undefined; + for (const write of deferredUserWrites.splice(0)) { + const outcome = await append(write); + if (!outcome) { + replayInvalid = true; + } + const didAppend = accept(outcome); + appended ||= didAppend; + } + await publish(appended); + }); + }, + barrier, + hasFailed: () => firstFailure !== undefined, + snapshot: () => ({ + assistantTranscriptOwned, + assistantTranscriptIdempotencyKey, + initialSdkUserValidated, + messagesSnapshot: [...messagesSnapshot], + replayInvalid, + }), + }; +} + +function resolveTranscriptTarget(attempt: AttemptParamsLike): SessionTranscriptTargetParams { + const sessionId = readString(attempt.sessionTarget?.sessionId); + const sessionKey = readString(attempt.sessionTarget?.sessionKey); + const storePath = readString(attempt.sessionTarget?.storePath); + if (!sessionId || !sessionKey || !storePath) { + const error = new Error( + "[copilot-attempt] canonical transcript persistence requires an exact runtime session target", + ) as Error & { code?: string }; + error.code = "transcript_persistence_failed"; + throw error; + } + const agentId = readString(attempt.sessionTarget?.agentId ?? attempt.agentId); + return { sessionId, sessionKey, storePath, ...(agentId ? { agentId } : {}) }; +} + +function readAssistantToolCallIds(message: TranscriptMessage): string[] { + return message.role === "assistant" + ? message.content.flatMap((part) => (part.type === "toolCall" ? [part.id] : [])) + : []; +} + +function isCompatibleSingletonRewrite( + original: TranscriptMessage, + prepared: TranscriptMessage, +): boolean { + // Hooks may redact content, but role and tool topology are journal-owned; + // accepting either rewrite would make the canonical replay structurally false. + return ( + original.role === prepared.role && + (original.role !== "assistant" || + JSON.stringify(readAssistantToolCallIds(original)) === + JSON.stringify(readAssistantToolCallIds(prepared))) + ); +} + +function projectReplayPayload(message: TranscriptMessage): unknown { + switch (message.role) { + case "user": + return { role: message.role, content: message.content }; + case "assistant": + return { + role: message.role, + content: message.content, + api: message.api, + model: message.model, + provider: message.provider, + stopReason: message.stopReason, + }; + case "toolResult": + return { + role: message.role, + content: message.content, + isError: message.isError, + toolCallId: message.toolCallId, + toolName: message.toolName, + }; + } + return undefined; +} + +function readIdempotencyKey(message: AgentMessage): string | undefined { + const key = (message as { idempotencyKey?: unknown }).idempotencyKey; + return typeof key === "string" && key ? key : undefined; +} + +function isCurrentJournalIdentity( + key: string, + params: { attempt: AttemptParamsLike; sdkSessionId: string }, +): boolean { + // Old mirror keys can be content fingerprints and are not turn identity. + // Current journal keys use a run id or the SDK's unique event id. + return ( + key === `${params.attempt.runId}:user` || key.startsWith(`copilot-sdk:${params.sdkSessionId}:`) + ); +} + +function isCompleteToolGroup(messages: TranscriptMessage[], order: string[]): boolean { + const [assistant, ...results] = messages; + return ( + assistant?.role === "assistant" && + JSON.stringify(readAssistantToolCallIds(assistant)) === JSON.stringify(order) && + results.length === order.length && + results.every( + (message, index) => message.role === "toolResult" && message.toolCallId === order[index], + ) + ); +} + +function isSameUserTurn( + candidate: AgentMessage | undefined, + current: Extract | undefined, + currentRunUserKey: string, +): boolean { + if (candidate?.role !== "user" || !current) { + return false; + } + if (candidate === current) { + return true; + } + const candidateKey = (candidate as { idempotencyKey?: unknown }).idempotencyKey; + const currentKey = (current as { idempotencyKey?: unknown }).idempotencyKey; + if (typeof candidateKey === "string" || typeof currentKey === "string") { + if (typeof candidateKey === "string" && typeof currentKey === "string") { + return candidateKey === currentKey; + } + if ( + typeof candidateKey !== "string" || + typeof currentKey === "string" || + (!candidateKey.startsWith("copilot:") && candidateKey !== currentRunUserKey) + ) { + return false; + } + } + // The embedded-runner boundary identifies the active user as the last user + // and stamps it with this recorder timestamp; historical turns are ineligible. + return ( + candidate.timestamp === current.timestamp && + userText(candidate.content) === userText(current.content) + ); +} + +function userText(content: unknown): string { + if (typeof content === "string") { + return content; + } + if (Array.isArray(content) && content.length === 1) { + const part = content[0] as { text?: unknown; type?: unknown }; + if (part?.type === "text" && typeof part.text === "string") { + return part.text; + } + } + return JSON.stringify(content) ?? ""; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} diff --git a/extensions/copilot/src/attempt-types.ts b/extensions/copilot/src/attempt-types.ts index d1030d49da00..e0bc5d69f8a8 100644 --- a/extensions/copilot/src/attempt-types.ts +++ b/extensions/copilot/src/attempt-types.ts @@ -33,7 +33,10 @@ export type AgentHarnessAttemptResult = Extract< { terminal: unknown } >; type AttemptTerminal = AgentHarnessAttemptResult["terminal"]; -export type AttemptResultWithSdkSessionId = AgentHarnessAttemptResult & { sdkSessionId?: string }; +export type AttemptResultWithSdkSessionId = AgentHarnessAttemptResult & { + journalValidated?: boolean; + sdkSessionId?: string; +}; export function withPromptFailure(terminal: AttemptTerminal, error: unknown): AttemptTerminal { return terminal.kind === "aborted" || terminal.kind === "timeout" ? { ...terminal, failure: { source: "prompt", error } } @@ -93,7 +96,10 @@ export type AttemptParamsLike = AgentHarnessAttemptParams & { enableSessionTelemetry?: boolean; hooksConfig?: CopilotHooksConfig; infiniteSessionConfig?: SessionConfig["infiniteSessions"]; - initialReplayState?: AgentHarnessAttemptParams["initialReplayState"] & { sdkSessionId?: string }; + initialReplayState?: AgentHarnessAttemptParams["initialReplayState"] & { + journalValidated?: boolean; + sdkSessionId?: string; + }; messages?: AgentMessage[]; model?: string | { api?: string; id?: string; input?: string[]; provider?: string }; onAssistantDelta?: (payload: OnAssistantDeltaPayload) => void | Promise; diff --git a/extensions/copilot/src/attempt.test.ts b/extensions/copilot/src/attempt.test.ts index 8a291fed682c..2a81d6f38646 100644 --- a/extensions/copilot/src/attempt.test.ts +++ b/extensions/copilot/src/attempt.test.ts @@ -97,22 +97,60 @@ type CopilotToolBridgeInput = Parameters[0]; const TINY_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACXBIWXMAAAsTAAALEwEAmpwYAAAADUlEQVR4nGP4////KwAJ5gPoxLp9owAAAABJRU5ErkJggg=="; -// Mock the dual-write transcript mirror so attempt tests do not touch the -// real filesystem. The mirror call site is exercised separately in -// dual-write-transcripts.test.ts and by the dedicated attempt -// dual-write tests below; the mocked module here just captures the -// invocation arguments without writing to disk. -const dualWriteMock = vi.hoisted(() => ({ - dualWriteCopilotTranscriptBestEffort: vi.fn().mockResolvedValue(undefined), - attachCopilotMirrorIdentity: (message: T, identity: string): T => { - const record = message as unknown as Record; - return { - ...record, - __openclaw: { ...(record["__openclaw"] as object | undefined), mirrorIdentity: identity }, - } as unknown as T; - }, +const transcriptRuntimeMock = vi.hoisted(() => ({ + append: vi.fn(async (params: Record) => { + const prepare = params.prepareMessageAfterIdempotencyCheck as + | ((message: unknown) => unknown) + | undefined; + const message = prepare ? prepare(params.message) : params.message; + return message + ? { + appended: true, + message, + messageId: (params.eventId as string | undefined) ?? "transcript-message", + } + : undefined; + }), + appendBatch: vi.fn(async (params: { messages: Array> }) => + params.messages.map((message) => ({ + appended: true, + message: message.message, + messageId: (message.eventId as string | undefined) ?? "transcript-message", + })), + ), + publish: vi.fn(async () => undefined), + appendStrict: vi.fn(async (params: Record) => { + const result = await transcriptRuntimeMock.append(params); + return result ? { kind: "result" as const, result } : { kind: "suppressed" as const }; + }), + readVisible: vi.fn(async () => []), })); -vi.mock("./dual-write-transcripts.js", () => dualWriteMock); +vi.mock("openclaw/plugin-sdk/session-transcript-runtime", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + appendSessionTranscriptMessageByIdentity: transcriptRuntimeMock.append, + appendSessionTranscriptMessageByIdentityStrict: transcriptRuntimeMock.appendStrict, + appendSessionTranscriptMessagesByIdentity: transcriptRuntimeMock.appendBatch, + publishSessionTranscriptUpdateByIdentity: transcriptRuntimeMock.publish, + readVisibleSessionTranscriptMessageEntries: transcriptRuntimeMock.readVisible, + }; +}); + +async function appendPreparedTranscriptMessage(params: Record) { + const prepare = params.prepareMessageAfterIdempotencyCheck as + | ((message: unknown) => unknown) + | undefined; + const message = prepare ? prepare(params.message) : params.message; + return message + ? { + appended: true, + message, + messageId: (params.eventId as string | undefined) ?? "transcript-message", + } + : undefined; +} // Mock the workspace-bootstrap loader so attempt tests do not perform // real filesystem reads (which add async ticks and would break the @@ -243,7 +281,11 @@ function createFakeSession(cfg: Record, id: string): FakeSessio cfg, disconnect: vi.fn<() => Promise>(async () => undefined), emit: (eventType: string, data: Record) => { - const event = makeEvent(eventType, data); + const { __eventId, ...eventData } = data; + const event = { + ...makeEvent(eventType, eventData), + ...(typeof __eventId === "string" ? { id: __eventId } : {}), + }; for (const listener of listeners.get(eventType) ?? []) { listener(event); } @@ -325,6 +367,31 @@ function makeFakeSdk( }; } +function makeUserTurnRecorder( + message: Extract, +): NonNullable { + let blocked = false; + let persisted = false; + return { + message, + resolveMessage: vi.fn(async () => message), + markRuntimePersistencePending: vi.fn(), + markRuntimePersisted: vi.fn(() => { + persisted = true; + }), + markBlocked: vi.fn(() => { + blocked = true; + }), + hasPersisted: () => persisted, + isBlocked: () => blocked, + hasRuntimePersistencePending: () => false, + waitForRuntimePersistence: vi.fn(async () => undefined), + persistApproved: vi.fn(async () => undefined), + persistBlocked: vi.fn(async () => undefined), + persistFallback: vi.fn(async () => undefined), + }; +} + function makeParams( overrides: Partial< AgentHarnessAttemptParams & { @@ -334,7 +401,7 @@ function makeParams( profileVersion?: string; useLoggedInUser?: boolean; }; - initialReplayState: { sdkSessionId?: string }; + initialReplayState: { journalValidated?: boolean; sdkSessionId?: string }; messages: AgentMessage[]; model: { api: string; id: string; provider: string }; onAssistantDelta: (payload: { delta: string; text: string }) => void | Promise; @@ -342,6 +409,8 @@ function makeParams( } > = {}, ): AgentHarnessAttemptParams { + const prompt = overrides.prompt ?? "hello"; + const transcriptPrompt = overrides.transcriptPrompt ?? prompt; return { agentDir: "C:\\copilot-home", agentId: "agent-1", @@ -355,7 +424,7 @@ function makeParams( provider: "github-copilot", ...(typeof overrides.model === "object" ? overrides.model : {}), }, - prompt: "hello", + prompt, runId: "run-1", sessionFile: "session.json", sessionId: "session-1", @@ -366,6 +435,11 @@ function makeParams( storePath: "openclaw-agent.sqlite", }, timeoutMs: 5000, + userTurnTranscriptRecorder: makeUserTurnRecorder({ + content: transcriptPrompt, + role: "user", + timestamp: 1, + }), workspaceDir: "C:\\workspace", ...overrides, } as unknown as AgentHarnessAttemptParams; @@ -601,6 +675,7 @@ describe("runCopilotAttempt", () => { onCreateSession: (session) => { activeSession = session; session.sendAndWait.mockImplementationOnce(async () => { + session.emit("user.message", { content: "hello" }); session.emit("session.compaction_start", {}); return makeAssistantMessageEvent("done"); }); @@ -686,6 +761,7 @@ describe("runCopilotAttempt", () => { const sdk = makeFakeSdk({ onCreateSession: (session) => { session.sendAndWait.mockImplementationOnce(async () => { + session.emit("user.message", { content: "hello" }); session.emit("session.compaction_start", {}); return makeAssistantMessageEvent("done"); }); @@ -708,6 +784,59 @@ describe("runCopilotAttempt", () => { expect(pool.release.mock.calls).toHaveLength(1); }); + it("does not delete a fresh session when its SDK user was never validated", async () => { + vi.useFakeTimers(); + const sdk = makeFakeSdk({ + onCreateSession: (session) => { + session.sendAndWait.mockImplementationOnce(async () => { + session.emit("session.compaction_start", {}); + return makeAssistantMessageEvent("done"); + }); + }, + }); + const pool = makeFakePool(sdk); + + const result = await runCopilotAttempt(makeParams(), { pool }); + expect( + (result as AgentHarnessAttemptResult & { journalValidated?: boolean }).journalValidated, + ).toBe(false); + + await vi.advanceTimersByTimeAsync(180_000); + + expect(sdk.sessions[0]?.disconnect).toHaveBeenCalledTimes(1); + expect(sdk.client.deleteSession).not.toHaveBeenCalled(); + expect(pool.release).toHaveBeenCalledTimes(1); + }); + + it("does not delete a resumed session when deferred compaction cannot complete", async () => { + vi.useFakeTimers(); + const sdk = makeFakeSdk({ + onResumeSession: (session) => { + session.sendAndWait.mockImplementationOnce(async () => { + session.emit("session.compaction_start", {}); + return makeAssistantMessageEvent("done"); + }); + }, + }); + const pool = makeFakePool(sdk); + + const result = await runCopilotAttempt( + makeParams({ initialReplayState: { sdkSessionId: "legacy-session" } as never }), + { pool }, + ); + + expect(result.terminal).toEqual({ kind: "ok" }); + expect(result.replayMetadata.replaySafe).toBe(false); + expect( + (result as AgentHarnessAttemptResult & { journalValidated?: boolean }).journalValidated, + ).toBe(false); + await vi.advanceTimersByTimeAsync(180_000); + + expect(sdk.sessions[0]?.disconnect).toHaveBeenCalledTimes(1); + expect(sdk.client.deleteSession).not.toHaveBeenCalled(); + expect(pool.release).toHaveBeenCalledTimes(1); + }); + it("cancels retained compaction when the caller aborts after a turn result", async () => { const controller = new AbortController(); const onDeferredCompaction = vi.fn(); @@ -716,6 +845,7 @@ describe("runCopilotAttempt", () => { onCreateSession: (session) => { activeSession = session; session.sendAndWait.mockImplementationOnce(async () => { + session.emit("user.message", { content: "hello" }); session.emit("session.compaction_start", {}); setTimeout(() => controller.abort(), 0); return makeAssistantMessageEvent("done"); @@ -1094,7 +1224,7 @@ describe("runCopilotAttempt", () => { await runCopilotAttempt(makeParams(), { pool }); const session = requireSession(sdk); - expect(session.on.mock.calls[0]?.[0]).toBe("assistant.message_delta"); + expect(session.on.mock.calls[0]?.[0]).toBe("user.message"); expect( expectDefined(session.on.mock.invocationCallOrder[0], "Copilot subscribe order"), ).toBeLessThan( @@ -1176,13 +1306,18 @@ describe("runCopilotAttempt", () => { it("resume path", async () => { const sdk = makeFakeSdk({ onResumeSession: (session) => { - session.sendAndWait.mockResolvedValueOnce(makeAssistantMessageEvent("resumed")); + session.sendAndWait.mockImplementationOnce(async () => { + session.emit("user.message", { content: "hello" }); + return makeAssistantMessageEvent("resumed"); + }); }, }); const pool = makeFakePool(sdk); - await runCopilotAttempt( - makeParams({ initialReplayState: { sdkSessionId: "resume-1" } as never }), + const result = await runCopilotAttempt( + makeParams({ + initialReplayState: { journalValidated: true, sdkSessionId: "resume-1" } as never, + }), { pool }, ); @@ -1192,6 +1327,10 @@ describe("runCopilotAttempt", () => { (requireResumeSessionConfig(sdk) as { continuePendingWork?: boolean }).continuePendingWork, ).toBe(false); expect(sdk.createSession).toHaveBeenCalledTimes(0); + expect(result.replayMetadata.replaySafe).toBe(true); + expect( + (result as AgentHarnessAttemptResult & { journalValidated?: boolean }).journalValidated, + ).toBe(true); }); it("replay-shim: replayInvalid:true forces createSession even when sdkSessionId is present", async () => { @@ -2090,6 +2229,7 @@ describe("runCopilotAttempt", () => { const sdk = makeFakeSdk({ onCreateSession: (session) => { session.sendAndWait.mockImplementationOnce(async () => { + session.emit("user.message", { content: "hello" }); session.emit("session.compaction_start", {}); return undefined; }); @@ -2185,6 +2325,7 @@ describe("runCopilotAttempt", () => { const sdk = makeFakeSdk({ onCreateSession: (session) => { session.sendAndWait.mockImplementationOnce(async () => { + session.emit("user.message", { content: "hello" }); session.emit("session.compaction_start", {}); return undefined; }); @@ -2210,6 +2351,7 @@ describe("runCopilotAttempt", () => { const sdk = makeFakeSdk({ onCreateSession: (session) => { session.sendAndWait.mockImplementationOnce(async () => { + session.emit("user.message", { content: "hello" }); session.emit("session.compaction_start", {}); return undefined; }); @@ -2233,9 +2375,6 @@ describe("runCopilotAttempt", () => { }); it("keeps the compaction timeout classification after deferred completion", async () => { - const mirror = createDeferred(); - dualWriteMock.dualWriteCopilotTranscriptBestEffort.mockClear(); - dualWriteMock.dualWriteCopilotTranscriptBestEffort.mockImplementationOnce(() => mirror.promise); const sdk = makeFakeSdk({ onCreateSession: (session) => { session.sendAndWait.mockImplementationOnce(async () => { @@ -2245,17 +2384,11 @@ describe("runCopilotAttempt", () => { }, }); - const attempt = runCopilotAttempt(makeParams(), { pool: makeFakePool(sdk) }); - await vi.waitFor(() => { - expect(dualWriteMock.dualWriteCopilotTranscriptBestEffort).toHaveBeenCalledTimes(1); - }); + const result = await runCopilotAttempt(makeParams(), { pool: makeFakePool(sdk) }); + expect(result.terminal).toMatchObject({ kind: "timeout", phase: "compaction" }); + sdk.sessions[0]?.emit("session.compaction_complete", { success: true }); sdk.sessions[0]?.emit("session.idle", {}); - mirror.resolve(); - - const result = await attempt; - - expect(result.terminal).toMatchObject({ kind: "timeout", phase: "compaction" }); }); it("G1: SDK timeout rejection (Error 'Timeout after Nms waiting for session.idle') sets timedOut, leaves promptError undefined, and does NOT abort the session", async () => { @@ -2269,9 +2402,10 @@ describe("runCopilotAttempt", () => { // the attempt as side-effect-safe. const sdk = makeFakeSdk({ onCreateSession: (session) => { - session.sendAndWait.mockRejectedValueOnce( - new Error("Timeout after 60000ms waiting for session.idle"), - ); + session.sendAndWait.mockImplementationOnce(async () => { + session.emit("user.message", { content: "hello" }); + throw new Error("Timeout after 60000ms waiting for session.idle"); + }); }, }); const pool = makeFakePool(sdk); @@ -2290,6 +2424,9 @@ describe("runCopilotAttempt", () => { // replay-shim incorrectly treated the attempt as side-effect-safe. expect(result.replayMetadata?.hadPotentialSideEffects).toBe(true); expect(result.replayMetadata?.replaySafe).toBe(false); + expect( + (result as AgentHarnessAttemptResult & { journalValidated?: boolean }).journalValidated, + ).toBe(false); sdk.sessions[0]?.emit("session.idle", {}); await vi.waitFor(() => { expect(sdk.sessions[0]?.disconnect).toHaveBeenCalledTimes(1); @@ -2439,7 +2576,10 @@ describe("runCopilotAttempt", () => { const error = new Error("send failed"); const sdk = makeFakeSdk({ onCreateSession: (session) => { - session.sendAndWait.mockRejectedValueOnce(error); + session.sendAndWait.mockImplementationOnce(async () => { + session.emit("user.message", { content: "hello" }); + throw error; + }); }, }); const pool = makeFakePool(sdk); @@ -2448,6 +2588,9 @@ describe("runCopilotAttempt", () => { const session = requireSession(sdk); expect(projectAgentRunAttemptTerminal(result.terminal).promptError).toBe(error); + expect( + (result as AgentHarnessAttemptResult & { journalValidated?: boolean }).journalValidated, + ).toBe(false); expect(session.off).toHaveBeenCalledTimes(session.on.mock.calls.length); expect(session.disconnect).toHaveBeenCalledTimes(1); expect(pool["release"]).toHaveBeenCalledTimes(1); @@ -2823,348 +2966,614 @@ describe("runCopilotAttempt", () => { }); }); - describe("dual-write transcript mirror", () => { + describe("canonical transcript journal", () => { afterEach(() => { - dualWriteMock.dualWriteCopilotTranscriptBestEffort.mockClear(); - dualWriteMock.dualWriteCopilotTranscriptBestEffort.mockResolvedValue(undefined); + transcriptRuntimeMock.append.mockClear(); + transcriptRuntimeMock.appendBatch.mockClear(); + transcriptRuntimeMock.appendStrict.mockClear(); + transcriptRuntimeMock.publish.mockClear(); + transcriptRuntimeMock.readVisible.mockClear(); }); - it("invokes dual-write mirror with runtime identity and scoped idempotencyScope", async () => { - dualWriteMock.dualWriteCopilotTranscriptBestEffort.mockClear(); - const sdk = makeFakeSdk({ - onCreateSession: (session) => { - session.sendAndWait.mockResolvedValueOnce(makeAssistantMessageEvent("done")); + it("persists the prepared user before provider dispatch and owns the terminal assistant", async () => { + const order: string[] = []; + transcriptRuntimeMock.append.mockImplementationOnce( + async (params: Record) => { + order.push("user"); + const prepare = params.prepareMessageAfterIdempotencyCheck as + | ((message: unknown) => unknown) + | undefined; + const message = prepare ? prepare(params.message) : params.message; + return { appended: true, message: message as object, messageId: "user-event" }; }, - }); - const pool = makeFakePool(sdk); - - await runCopilotAttempt( - makeParams({ - sessionTarget: { - sessionId: "session-1", - sessionKey: "agent:main:session-1", - storePath: "sessions.json", - }, - }), - { pool }, ); - - expect(dualWriteMock.dualWriteCopilotTranscriptBestEffort).toHaveBeenCalledTimes(1); - const args = dualWriteMock.dualWriteCopilotTranscriptBestEffort.mock.calls[0]?.[0] as { - sessionId: string; - sessionKey: string; - storePath?: string; - messages: Array<{ role: string }>; - idempotencyScope?: string; - }; - expect(args.sessionId).toBe("session-1"); - expect(args.sessionKey).toBe("agent:main:session-1"); - expect(args.storePath).toBe("sessions.json"); - expect(args.idempotencyScope).toBe("copilot:sess-1"); - expect(args.messages.length).toBeGreaterThan(0); - const roles = args.messages.map((m) => m.role); - expect(roles).toContain("user"); - expect(roles).toContain("assistant"); - }); - - it("keeps current memory-maintenance visibility hidden across later replays", async () => { const sdk = makeFakeSdk({ onCreateSession: (session) => { - session.sendAndWait.mockResolvedValueOnce(makeAssistantMessageEvent("NO_REPLY")); + session.sendAndWait.mockImplementationOnce(async () => { + order.push("dispatch"); + return makeAssistantMessageEvent("done"); + }); }, }); - const pool = makeFakePool(sdk); - const priorAssistant = { - role: "assistant", - content: [{ type: "text", text: "ordinary prior reply" }], - timestamp: Date.now() - 1, - } as AgentMessage; - const currentUser = { + + const result = await runCopilotAttempt(makeParams(), { pool: makeFakePool(sdk) }); + + expect(order).toEqual(["user", "dispatch"]); + expect(result.assistantTranscriptOwned).toBe(true); + expect(result.assistantTranscriptIdempotencyKey).toBe( + "copilot-sdk:sess-1:assistant.message-id", + ); + expect(result.messagesSnapshot.map((message) => message.role)).toEqual(["user", "assistant"]); + }); + + it("invalidates replay when storage rewrites a singleton payload", async () => { + transcriptRuntimeMock.appendStrict.mockImplementationOnce(async (params) => { + const stored = await appendPreparedTranscriptMessage(params); + if (!stored) { + return { kind: "suppressed" as const }; + } + return { + kind: "result" as const, + result: { + ...stored, + message: { ...stored.message, content: "[storage-redacted]" }, + }, + }; + }); + + const result = await runCopilotAttempt(makeParams(), { pool: makeFakePool(makeFakeSdk()) }); + + expect(result.replayMetadata.replaySafe).toBe(false); + expect(result.messagesSnapshot[0]).toMatchObject({ role: "user", - content: "Pre-compaction memory flush", - timestamp: Date.now(), - } as AgentMessage; + content: "[storage-redacted]", + }); + }); + + it("fails before dispatch when the exact transcript target is absent", async () => { + const sdk = makeFakeSdk(); + const params = makeParams({ trigger: "memory" }) as AgentHarnessAttemptParams & { + sessionTarget?: unknown; + }; + delete params.sessionTarget; + delete params.userTurnTranscriptRecorder; + + const result = await runCopilotAttempt(params, { pool: makeFakePool(sdk) }); + + expect(projectAgentRunAttemptTerminal(result.terminal).promptError).toMatchObject({ + code: "transcript_persistence_failed", + }); + expect(requireSession(sdk).sendAndWait).not.toHaveBeenCalled(); + expect(result.replayMetadata.replaySafe).toBe(false); + expect(sdk.client.deleteSession).not.toHaveBeenCalled(); + expect(result.messagesSnapshot.at(-1)).toMatchObject({ role: "user" }); + }); + + it("keeps the prepared recorder user when journal setup fails before assignment", async () => { + const sdk = makeFakeSdk(); + const params = makeParams({ messages: [] }) as AgentHarnessAttemptParams & { + sessionTarget?: unknown; + }; + delete params.sessionTarget; + + const result = await runCopilotAttempt(params, { pool: makeFakePool(sdk) }); + + expect(projectAgentRunAttemptTerminal(result.terminal).promptError).toMatchObject({ + code: "transcript_persistence_failed", + }); + expect(result.messagesSnapshot).toMatchObject([ + { role: "user", content: params.userTurnTranscriptRecorder?.message?.content }, + ]); + expect( + (result as AgentHarnessAttemptResult & { journalValidated?: boolean }).journalValidated, + ).toBe(false); + }); + + it("keeps a pre-journal memory user hidden when setup fails", async () => { + const sdk = makeFakeSdk(); + const params = makeParams({ + messages: [], + trigger: "memory", + }) as AgentHarnessAttemptParams & { + sessionTarget?: unknown; + }; + delete params.sessionTarget; + + const result = await runCopilotAttempt(params, { pool: makeFakePool(sdk) }); + + expect(result.messagesSnapshot).toMatchObject([ + { role: "user", content: "hello", display: false }, + ]); + }); + + it("does not restore an already-blocked user when pre-journal setup fails", async () => { + const sdk = makeFakeSdk(); + const recorder = makeUserTurnRecorder({ role: "user", content: "blocked", timestamp: 1 }); + recorder.markBlocked(); + const params = makeParams({ + messages: [{ role: "user", content: "blocked", timestamp: 1 }], + userTurnTranscriptRecorder: recorder, + }) as AgentHarnessAttemptParams & { sessionTarget?: unknown }; + delete params.sessionTarget; + + const result = await runCopilotAttempt(params, { pool: makeFakePool(sdk) }); + + expect(result.messagesSnapshot).toEqual([]); + }); + + it("does not restore a keyed blocked user when pre-journal setup fails", async () => { + const sdk = makeFakeSdk(); + const current = { + role: "user", + content: "blocked", + idempotencyKey: "run-1:user", + timestamp: 1, + } as Extract & { idempotencyKey: string }; + const recorder = makeUserTurnRecorder(current); + recorder.markBlocked(); + const params = makeParams({ + messages: [current], + userTurnTranscriptRecorder: recorder, + }) as AgentHarnessAttemptParams & { sessionTarget?: unknown }; + delete params.sessionTarget; + + const result = await runCopilotAttempt(params, { pool: makeFakePool(sdk) }); + + expect(result.messagesSnapshot).toEqual([]); + }); + + it("replaces the common unkeyed active user in the pre-journal fallback", async () => { + const sdk = makeFakeSdk(); + const params = makeParams() as AgentHarnessAttemptParams & { sessionTarget?: unknown }; + delete params.sessionTarget; + + const result = await runCopilotAttempt(params, { pool: makeFakePool(sdk) }); + + expect(result.messagesSnapshot).toMatchObject([ + { role: "user", content: "hello", timestamp: 1 }, + ]); + }); + + it("does not collapse distinct repeated users in the pre-journal fallback", async () => { + const sdk = makeFakeSdk(); + const recorder = makeUserTurnRecorder({ role: "user", content: "repeat", timestamp: 2 }); + const params = makeParams({ + messages: [{ role: "user", content: "repeat", timestamp: 1 }], + prompt: "repeat", + userTurnTranscriptRecorder: recorder, + }) as AgentHarnessAttemptParams & { sessionTarget?: unknown }; + delete params.sessionTarget; + + const result = await runCopilotAttempt(params, { pool: makeFakePool(sdk) }); + + expect(result.messagesSnapshot).toMatchObject([ + { role: "user", content: "repeat", timestamp: 1 }, + { role: "user", content: "repeat", timestamp: 2 }, + ]); + }); + + it("does not collapse distinct repeated users in the normal journal path", async () => { + const recorder = makeUserTurnRecorder({ role: "user", content: "repeat", timestamp: 2 }); const result = await runCopilotAttempt( makeParams({ - messages: [priorAssistant, currentUser], - prompt: "Pre-compaction memory flush", - trigger: "memory", + messages: [ + { + role: "user", + content: "repeat", + idempotencyKey: "copilot:legacy:user:content-fingerprint", + timestamp: 1, + } as AgentMessage, + ], + prompt: "repeat", + userTurnTranscriptRecorder: recorder, }), - { pool }, + { pool: makeFakePool(makeFakeSdk()) }, ); - const [replayedPrior, persistedPrompt, persistedAssistant] = - result.messagesSnapshot as Array<{ - display?: boolean; - role: string; - }>; - expect(replayedPrior).not.toHaveProperty("display", false); - expect(persistedPrompt).toMatchObject({ display: false, role: "user" }); - expect(persistedAssistant).toMatchObject({ display: false, role: "assistant" }); + expect(result.messagesSnapshot.slice(0, 2)).toMatchObject([ + { role: "user", content: "repeat", timestamp: 1 }, + { role: "user", content: "repeat", timestamp: 2 }, + ]); }); - it("does not invoke dual-write mirror when runtime identity is absent", async () => { - dualWriteMock.dualWriteCopilotTranscriptBestEffort.mockClear(); - const sdk = makeFakeSdk({ - onCreateSession: (session) => { - session.sendAndWait.mockResolvedValueOnce(makeAssistantMessageEvent("done")); - }, - }); - const pool = makeFakePool(sdk); - const params = makeParams() as unknown as Record; - delete params.sessionTarget; + it("replaces the active legacy-keyed user instead of duplicating it", async () => { + const recorder = makeUserTurnRecorder({ role: "user", content: "active", timestamp: 2 }); - await runCopilotAttempt(params as never, { pool }); - - expect(dualWriteMock.dualWriteCopilotTranscriptBestEffort).not.toHaveBeenCalled(); - }); - - it("tags mirrored messages with copilot mirror identity per role and position", async () => { - dualWriteMock.dualWriteCopilotTranscriptBestEffort.mockClear(); - const sdk = makeFakeSdk({ - onCreateSession: (session) => { - session.sendAndWait.mockResolvedValueOnce(makeAssistantMessageEvent("done")); - }, - }); - const pool = makeFakePool(sdk); - - await runCopilotAttempt(makeParams(), { pool }); - - const args = dualWriteMock.dualWriteCopilotTranscriptBestEffort.mock.calls[0]?.[0] as { - messages: Array<{ - role: string; - idempotencyKey?: string; - __openclaw?: { mirrorIdentity?: string }; - }>; - }; - for (const [index, message] of args.messages.entries()) { - if ( - message.role !== "user" && - message.role !== "assistant" && - message.role !== "toolResult" - ) { - continue; - } - const identity = message["__openclaw"]?.mirrorIdentity ?? ""; - // The current user and terminal assistant carry turn-stable identities. - // Caller-passed history without an identity falls through to - // the positional `${scope}:role:idx`. - if (message.role === "user" && message.idempotencyKey === "run-1:user") { - expect(identity).toBe("run-1:prompt"); - } else if (message.role === "assistant" && index === args.messages.length - 1) { - expect(identity).toMatch(/:assistant:final$/u); - expect(identity).toContain("run-1"); - } else { - expect(identity).toMatch(new RegExp(`:${message.role}:${index}$`, "u")); - } - } - }); - - it("dual-write failure does not surface from runCopilotAttempt", async () => { - dualWriteMock.dualWriteCopilotTranscriptBestEffort.mockRejectedValueOnce( - new Error("mirror boom"), + const result = await runCopilotAttempt( + makeParams({ + messages: [ + { + role: "user", + content: "active", + idempotencyKey: "copilot:legacy:user:content-fingerprint", + timestamp: 2, + } as AgentMessage, + ], + prompt: "active", + userTurnTranscriptRecorder: recorder, + }), + { pool: makeFakePool(makeFakeSdk()) }, ); - const sdk = makeFakeSdk({ - onCreateSession: (session) => { - session.sendAndWait.mockResolvedValueOnce(makeAssistantMessageEvent("done")); - }, - }); - const pool = makeFakePool(sdk); - // dualWriteCopilotTranscriptBestEffort is already best-effort - // internally; this test asserts attempt.ts also awaits it without - // letting an unexpected rejection escape. - await expect(runCopilotAttempt(makeParams(), { pool })).resolves.toBeDefined(); + expect(result.messagesSnapshot.map((message) => message.role)).toEqual(["user", "assistant"]); + expect(result.messagesSnapshot[0]).toMatchObject({ + content: "active", + idempotencyKey: "run-1:user", + }); }); - // --------------------------------------------------------------- - // Dogfood finding #3: synthetic current-turn user message in the - // OpenClaw audit transcript (mirrors codex event-projector pattern). - // - // Without this synthesis the dashboard / CLI history shows only - // assistant bubbles — the user's typed turn is lost — because the - // OpenClaw shell's `persistTextTurnTranscript` skips its own user - // write when `embeddedAssistantGapFill` is true, trusting the - // harness to mirror the user turn. - // --------------------------------------------------------------- - it("injects synthetic user message with runId:prompt identity when caller passes no history", async () => { - dualWriteMock.dualWriteCopilotTranscriptBestEffort.mockClear(); - const sdk = makeFakeSdk({ - onCreateSession: (session) => { - session.sendAndWait.mockResolvedValueOnce(makeAssistantMessageEvent("done")); - }, + it("retains a keyed current user after replacing its staged snapshot", async () => { + const current = { + role: "user", + content: "keyed current", + idempotencyKey: "run-1:user", + timestamp: 2, + } as Extract & { idempotencyKey: string }; + const recorder = makeUserTurnRecorder(current); + + const result = await runCopilotAttempt( + makeParams({ + messages: [current], + prompt: "keyed current", + userTurnTranscriptRecorder: recorder, + }), + { pool: makeFakePool(makeFakeSdk()) }, + ); + + expect(result.messagesSnapshot.map((message) => message.role)).toEqual(["user", "assistant"]); + expect(result.messagesSnapshot[0]).toMatchObject({ idempotencyKey: "run-1:user" }); + }); + + it("fails closed, aborts once, and invalidates replay after an append rejection", async () => { + const appendError = new Error("sqlite unavailable"); + transcriptRuntimeMock.append.mockRejectedValueOnce(appendError); + const sdk = makeFakeSdk(); + + const result = await runCopilotAttempt(makeParams({ trigger: "memory" }), { + pool: makeFakePool(sdk), }); - const pool = makeFakePool(sdk); - const params = makeParams({ - messages: [], - prompt: "what's my name?", - runId: "run-A", + + expect(projectAgentRunAttemptTerminal(result.terminal).promptError).toMatchObject({ + code: "transcript_persistence_failed", + cause: appendError, + }); + expect(requireSession(sdk).abort).toHaveBeenCalledTimes(1); + expect(requireSession(sdk).sendAndWait).not.toHaveBeenCalled(); + expect(requireSession(sdk).disconnect).toHaveBeenCalledTimes(1); + expect(sdk.client.deleteSession).not.toHaveBeenCalled(); + expect(result.replayMetadata.replaySafe).toBe(false); + expect(result.messagesSnapshot.at(-1)).toMatchObject({ display: false }); + }); + + it("fails closed instead of treating a singleton session rebind as policy suppression", async () => { + transcriptRuntimeMock.appendStrict.mockResolvedValueOnce({ + kind: "rejected", + reason: "session-rebound", } as never); + const sdk = makeFakeSdk(); - await runCopilotAttempt(params, { pool }); + const result = await runCopilotAttempt(makeParams(), { pool: makeFakePool(sdk) }); - const args = dualWriteMock.dualWriteCopilotTranscriptBestEffort.mock.calls[0]?.[0] as { - messages: Array<{ - role: string; - content: unknown; - idempotencyKey?: string; - __openclaw?: { mirrorIdentity?: string }; - }>; - }; - expect(args.messages.length).toBe(2); - expect(args.messages[0]?.role).toBe("user"); - expect(args.messages[0]?.content).toBe("what's my name?"); - expect(args.messages[0]?.idempotencyKey).toBe("run-A:user"); - expect(args.messages[0]?.["__openclaw"]?.mirrorIdentity).toBe("run-A:prompt"); - expect(args.messages[1]?.role).toBe("assistant"); - expect(args.messages[1]?.["__openclaw"]?.mirrorIdentity).toBe("run-A:assistant:final"); + expect(projectAgentRunAttemptTerminal(result.terminal).promptError).toMatchObject({ + code: "transcript_persistence_failed", + cause: expect.objectContaining({ + message: "Transcript session changed before singleton append", + }), + }); + expect(requireSession(sdk).sendAndWait).not.toHaveBeenCalled(); + expect(result.assistantTranscriptOwned).toBeUndefined(); }); - it("does not duplicate synthetic user when caller passed the same prompt as the messages tail", async () => { - dualWriteMock.dualWriteCopilotTranscriptBestEffort.mockClear(); + it("fails closed when an assistant append rejects", async () => { + const appendError = new Error("assistant append failed"); + transcriptRuntimeMock.append + .mockImplementationOnce(appendPreparedTranscriptMessage) + .mockRejectedValueOnce(appendError); + const sdk = makeFakeSdk(); + + const result = await runCopilotAttempt(makeParams(), { pool: makeFakePool(sdk) }); + + expect(projectAgentRunAttemptTerminal(result.terminal).promptError).toMatchObject({ + code: "transcript_persistence_failed", + cause: appendError, + }); + expect(requireSession(sdk).abort).toHaveBeenCalledTimes(1); + expect(result.assistantTranscriptOwned).toBeUndefined(); + expect(result.replayMetadata.replaySafe).toBe(false); + }); + + it("fails closed when an ordered tool-result append rejects", async () => { + const appendError = new Error("tool append failed"); + transcriptRuntimeMock.append.mockImplementationOnce(appendPreparedTranscriptMessage); + transcriptRuntimeMock.appendBatch.mockRejectedValueOnce(appendError); const sdk = makeFakeSdk({ onCreateSession: (session) => { - session.sendAndWait.mockResolvedValueOnce(makeAssistantMessageEvent("done")); + session.sendAndWait.mockImplementationOnce(async () => { + session.emit("assistant.message", { + content: "", + messageId: "tools", + toolRequests: [{ name: "read", toolCallId: "call-1" }], + }); + session.emit("tool.execution_start", { + toolCallId: "call-1", + toolName: "read", + }); + session.emit("tool.execution_complete", { + result: { content: "done" }, + success: true, + toolCallId: "call-1", + }); + session.emit("assistant.message", { + __eventId: "assistant-final", + content: "final after tool", + messageId: "final", + }); + return undefined; + }); }, }); - const pool = makeFakePool(sdk); - // Default makeParams() seeds messages with the same text as - // prompt, so the synthetic user should be suppressed and the - // mirrored payload should contain exactly one user entry. - await runCopilotAttempt(makeParams(), { pool }); - const args = dualWriteMock.dualWriteCopilotTranscriptBestEffort.mock.calls[0]?.[0] as { - messages: Array<{ role: string; idempotencyKey?: string }>; - }; - const userCount = args.messages.filter((m) => m.role === "user").length; - expect(userCount).toBe(1); - expect(args.messages.find((message) => message.role === "user")?.idempotencyKey).toBe( - "run-1:user", + const result = await runCopilotAttempt(makeParams(), { pool: makeFakePool(sdk) }); + + expect(projectAgentRunAttemptTerminal(result.terminal).promptError).toMatchObject({ + code: "transcript_persistence_failed", + cause: appendError, + }); + expect(requireSession(sdk).abort).toHaveBeenCalledTimes(1); + expect(transcriptRuntimeMock.append).toHaveBeenCalledOnce(); + expect(transcriptRuntimeMock.appendBatch).toHaveBeenCalledOnce(); + expect(result.assistantTranscriptOwned).toBeUndefined(); + expect(result.replayMetadata.replaySafe).toBe(false); + }); + + it("invalidates replay when storage rewrites a tool-group payload", async () => { + transcriptRuntimeMock.appendBatch.mockImplementationOnce(async (params) => + params.messages.map((message, index) => ({ + appended: true, + message: + index === 1 + ? { + ...(message.message as object), + content: [{ type: "text", text: "[storage-redacted]" }], + } + : message.message, + messageId: (message.eventId as string | undefined) ?? "transcript-message", + })), ); - }); - - it("prefers transcriptPrompt over prompt for the synthetic user body", async () => { - dualWriteMock.dualWriteCopilotTranscriptBestEffort.mockClear(); const sdk = makeFakeSdk({ onCreateSession: (session) => { - session.sendAndWait.mockResolvedValueOnce(makeAssistantMessageEvent("done")); + session.sendAndWait.mockImplementationOnce(async () => { + session.emit("assistant.message", { + content: "checking", + messageId: "tools", + toolRequests: [{ name: "read", toolCallId: "call-1" }], + }); + session.emit("tool.execution_complete", { + result: { content: "done" }, + success: true, + toolCallId: "call-1", + }); + const final = makeAssistantMessageEvent("final after tool"); + session.emit("assistant.message", { __eventId: "assistant-final", ...final.data }); + return final; + }); }, }); - const pool = makeFakePool(sdk); - const params = makeParams({ - messages: [], - prompt: "EXPANDED: please answer with your real name", - transcriptPrompt: "what's your name?", - runId: "run-B", - } as never); - await runCopilotAttempt(params, { pool }); + const result = await runCopilotAttempt(makeParams(), { pool: makeFakePool(sdk) }); - const args = dualWriteMock.dualWriteCopilotTranscriptBestEffort.mock.calls[0]?.[0] as { - messages: Array<{ role: string; content: unknown }>; - }; - const user = args.messages.find((m) => m.role === "user"); - expect(user?.content).toBe("what's your name?"); + expect(result.terminal).toEqual({ kind: "ok" }); + expect(result.replayMetadata.replaySafe).toBe(false); + expect( + result.messagesSnapshot.find((message) => message.role === "toolResult"), + ).toMatchObject({ + content: [{ type: "text", text: "[storage-redacted]" }], + }); }); - it("two attempts that share the same sdkSessionId but differ by runId produce distinct user/assistant mirror identities", async () => { - // Simulates session reuse (Fix B): the SDK keeps `sess-1` across - // both turns, so a session-relative `${sdkSessionId}:user:0` - // identity would collide and drop the second turn's user message. - // The runId-stable identity scheme avoids that collision. - dualWriteMock.dualWriteCopilotTranscriptBestEffort.mockClear(); + it("treats before_message_write blocking as authoritative ownership", async () => { + initializeGlobalHookRunner( + createMockPluginRegistry([ + { + hookName: "before_message_write", + handler: (event: unknown) => + (event as { message: AgentMessage }).message.role === "assistant" + ? { block: true } + : undefined, + }, + ]), + ); const sdk = makeFakeSdk({ onCreateSession: (session) => { - session.sendAndWait.mockResolvedValueOnce(makeAssistantMessageEvent("turn-1-reply")); - }, - onResumeSession: (session) => { - session.sendAndWait.mockResolvedValueOnce(makeAssistantMessageEvent("turn-2-reply")); + session.sendAndWait.mockImplementationOnce(async () => { + const assistant = makeAssistantMessageEvent("", { + toolRequests: [{ name: "read", toolCallId: "blocked-call" }], + }); + session.emit("assistant.message", assistant.data); + session.emit("tool.execution_complete", { + result: { content: "must stay suppressed" }, + success: true, + toolCallId: "blocked-call", + }); + return assistant; + }); }, }); - const pool = makeFakePool(sdk); - await runCopilotAttempt( + const result = await runCopilotAttempt(makeParams(), { pool: makeFakePool(sdk) }); + + expect(result.terminal).toEqual({ kind: "ok" }); + expect(result.assistantTranscriptOwned).toBe(true); + expect(result.assistantTranscriptIdempotencyKey).toBeUndefined(); + expect(result.messagesSnapshot.some((message) => message.role === "assistant")).toBe(false); + expect(result.messagesSnapshot.some((message) => message.role === "toolResult")).toBe(false); + }); + + it("invalidates native replay when policy blocks a persisted tool result", async () => { + initializeGlobalHookRunner( + createMockPluginRegistry([ + { + hookName: "before_message_write", + handler: (event: unknown) => + (event as { message: AgentMessage }).message.role === "toolResult" + ? { block: true } + : undefined, + }, + ]), + ); + const sdk = makeFakeSdk({ + onCreateSession: (session) => { + session.sendAndWait.mockImplementationOnce(async () => { + session.emit("assistant.message", { + content: "", + messageId: "tools", + toolRequests: [{ name: "read", toolCallId: "policy-call" }], + }); + session.emit("tool.execution_complete", { + result: { content: "blocked by policy" }, + success: true, + toolCallId: "policy-call", + }); + session.emit("session.compaction_start", {}); + const final = { ...makeAssistantMessageEvent("done"), id: "final" }; + session.emit("assistant.message", { __eventId: "final", ...final.data }); + return final; + }); + }, + }); + + const result = await runCopilotAttempt(makeParams(), { pool: makeFakePool(sdk) }); + + expect(result.terminal).toEqual({ kind: "ok" }); + expect(requireSession(sdk).abort).not.toHaveBeenCalled(); + expect(requireSession(sdk).disconnect).toHaveBeenCalledTimes(1); + expect(sdk.client.deleteSession).not.toHaveBeenCalled(); + expect(result.replayMetadata.replaySafe).toBe(false); + expect(result.messagesSnapshot.some((message) => message.role === "toolResult")).toBe(false); + expect(result.assistantTranscriptOwned).toBe(true); + }); + + it("preserves stable idempotency keys across hook replacements", async () => { + initializeGlobalHookRunner( + createMockPluginRegistry([ + { + hookName: "before_message_write", + handler: (event: unknown) => { + const message = ( + event as { + message: AgentMessage & { idempotencyKey?: string }; + } + ).message; + delete message.idempotencyKey; + return { message }; + }, + }, + ]), + ); + + await runCopilotAttempt(makeParams(), { pool: makeFakePool(makeFakeSdk()) }); + + expect(transcriptRuntimeMock.append.mock.calls[0]?.[0]).toMatchObject({ + message: { idempotencyKey: "run-1:user" }, + }); + expect(transcriptRuntimeMock.append.mock.calls[1]?.[0]).toMatchObject({ + message: { idempotencyKey: "copilot-sdk:sess-1:assistant.message-id" }, + }); + }); + + it("removes an explicitly blocked memory user from the returned snapshot", async () => { + const recorder = makeUserTurnRecorder({ role: "user", content: "memory", timestamp: 1 }); + recorder.markBlocked(); + + const result = await runCopilotAttempt( makeParams({ - messages: [], - prompt: "turn 1", - runId: "run-1", - } as never), - { pool }, - ); - await runCopilotAttempt( - makeParams({ - messages: [], - prompt: "turn 2", - runId: "run-2", - initialReplayState: { sdkSessionId: "sess-1" }, - } as never), - { pool }, + messages: [{ role: "user", content: "memory", timestamp: 1 }], + trigger: "memory", + userTurnTranscriptRecorder: recorder, + }), + { pool: makeFakePool(makeFakeSdk()) }, ); - const calls = dualWriteMock.dualWriteCopilotTranscriptBestEffort.mock.calls; - expect(calls.length).toBe(2); - const turn1 = calls[0]?.[0] as { - messages: Array<{ role: string; __openclaw?: { mirrorIdentity?: string } }>; - }; - const turn2 = calls[1]?.[0] as { - messages: Array<{ role: string; __openclaw?: { mirrorIdentity?: string } }>; - }; - const turn1User = turn1.messages.find((m) => m.role === "user"); - const turn2User = turn2.messages.find((m) => m.role === "user"); - const turn1Assistant = turn1.messages.find((m) => m.role === "assistant"); - const turn2Assistant = turn2.messages.find((m) => m.role === "assistant"); - expect(turn1User?.["__openclaw"]?.mirrorIdentity).toBe("run-1:prompt"); - expect(turn2User?.["__openclaw"]?.mirrorIdentity).toBe("run-2:prompt"); - expect(turn1Assistant?.["__openclaw"]?.mirrorIdentity).toBe("run-1:assistant:final"); - expect(turn2Assistant?.["__openclaw"]?.mirrorIdentity).toBe("run-2:assistant:final"); + expect(result.messagesSnapshot.map((message) => message.role)).toEqual(["assistant"]); + expect(result.messagesSnapshot[0]).toMatchObject({ display: false }); }); - it("two attempts with identical prompts but different runIds remain distinct (no content-fingerprint collapse)", async () => { - dualWriteMock.dualWriteCopilotTranscriptBestEffort.mockClear(); - const sdk = makeFakeSdk({ - onCreateSession: (session) => { - session.sendAndWait.mockResolvedValueOnce(makeAssistantMessageEvent("first")); - }, - onResumeSession: (session) => { - session.sendAndWait.mockResolvedValueOnce(makeAssistantMessageEvent("second")); - }, - }); - const pool = makeFakePool(sdk); + it("removes a keyed blocked user from the returned snapshot", async () => { + const current = { + role: "user", + content: "blocked", + idempotencyKey: "run-1:user", + timestamp: 1, + } as Extract & { idempotencyKey: string }; + const recorder = makeUserTurnRecorder(current); + recorder.markBlocked(); - await runCopilotAttempt( - makeParams({ messages: [], prompt: "same question", runId: "run-X" } as never), - { pool }, + const result = await runCopilotAttempt( + makeParams({ messages: [current], userTurnTranscriptRecorder: recorder }), + { pool: makeFakePool(makeFakeSdk()) }, ); - await runCopilotAttempt( + + expect(result.messagesSnapshot.map((message) => message.role)).toEqual(["assistant"]); + }); + + it("preserves an unrelated user tail when persisting the current turn", async () => { + const result = await runCopilotAttempt( makeParams({ - messages: [], - prompt: "same question", - runId: "run-Y", - initialReplayState: { sdkSessionId: "sess-1" }, - } as never), - { pool }, + messages: [{ role: "user", content: "older unanswered turn", timestamp: 1 }], + prompt: "new turn", + }), + { pool: makeFakePool(makeFakeSdk()) }, ); - const calls = dualWriteMock.dualWriteCopilotTranscriptBestEffort.mock.calls; - const firstCall = expectDefined(calls[0], "first Copilot transcript mirror call"); - const secondCall = expectDefined(calls[1], "second Copilot transcript mirror call"); - const id1 = ( - firstCall[0] as { - messages: Array<{ role: string; __openclaw?: { mirrorIdentity?: string } }>; - } - ).messages.find((m) => m.role === "user")?.["__openclaw"]?.mirrorIdentity; - const id2 = ( - secondCall[0] as { - messages: Array<{ role: string; __openclaw?: { mirrorIdentity?: string } }>; - } - ).messages.find((m) => m.role === "user")?.["__openclaw"]?.mirrorIdentity; - expect(id1).toBe("run-X:prompt"); - expect(id2).toBe("run-Y:prompt"); - expect(id1).not.toBe(id2); + expect(result.messagesSnapshot.map((message) => message.role)).toEqual([ + "user", + "user", + "assistant", + ]); + expect( + result.messagesSnapshot + .slice(0, 2) + .map((message) => (message as { content?: unknown }).content), + ).toEqual(["older unanswered turn", "new turn"]); + }); + + it("replaces equivalent string and text-block current-user representations", async () => { + const result = await runCopilotAttempt( + makeParams({ + messages: [{ role: "user", content: [{ type: "text", text: "hello" }], timestamp: 1 }], + }), + { pool: makeFakePool(makeFakeSdk()) }, + ); + + expect(result.messagesSnapshot.map((message) => message.role)).toEqual(["user", "assistant"]); + }); + + it("keeps a keyed earlier turn when the current prompt repeats its text", async () => { + const result = await runCopilotAttempt( + makeParams({ + messages: [ + { + role: "user", + content: "hello", + idempotencyKey: "older-run:user", + timestamp: 1, + } as AgentMessage, + ], + }), + { pool: makeFakePool(makeFakeSdk()) }, + ); + + expect(result.messagesSnapshot.map((message) => message.role)).toEqual([ + "user", + "user", + "assistant", + ]); + expect( + (result.messagesSnapshot[0] as AgentMessage & { idempotencyKey?: string }).idempotencyKey, + ).toBe("older-run:user"); }); }); - describe("sandbox parity (PR #86155 [P1])", () => { function makeSandboxStub(overrides: Partial = {}): SandboxContext { return { diff --git a/extensions/copilot/src/dual-write-transcripts.test.ts b/extensions/copilot/src/dual-write-transcripts.test.ts deleted file mode 100755 index 95b26adfbfd5..000000000000 --- a/extensions/copilot/src/dual-write-transcripts.test.ts +++ /dev/null @@ -1,502 +0,0 @@ -// Copilot tests cover dual write transcripts plugin behavior. -import { createHash } from "node:crypto"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import type { AgentMessage } from "openclaw/plugin-sdk/agent-harness-runtime"; -import { - initializeGlobalHookRunner, - resetGlobalHookRunner, -} from "openclaw/plugin-sdk/hook-runtime"; -import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime"; -import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; -import { readSessionTranscriptEvents } from "openclaw/plugin-sdk/session-transcript-runtime"; -import { - castAgentMessage, - makeAgentAssistantMessage, - makeAgentUserMessage, -} from "openclaw/plugin-sdk/test-fixtures"; -import { afterEach, describe, expect, it } from "vitest"; -import { - attachCopilotMirrorIdentity, - dualWriteCopilotTranscriptBestEffort, -} from "./dual-write-transcripts.js"; - -const mirrorCopilotTranscript = dualWriteCopilotTranscriptBestEffort; - -type MirroredAgentMessage = Extract; - -function expectedFingerprint(message: MirroredAgentMessage): string { - const payload = JSON.stringify({ role: message.role, content: message.content }); - return createHash("sha256").update(payload).digest("hex").slice(0, 16); -} - -const tempDirs: string[] = []; - -afterEach(async () => { - resetGlobalHookRunner(); - for (const dir of tempDirs.splice(0)) { - await fs.rm(dir, { recursive: true, force: true }); - } -}); - -async function makeRoot(prefix: string): Promise { - const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(root); - return root; -} - -function readEventMessages(events: unknown[]): Array<{ role?: string; text?: string }> { - return events - .map((event) => - event && typeof event === "object" ? (event as { message?: unknown }).message : undefined, - ) - .filter((message): message is { role?: string; content?: unknown } => - Boolean(message && typeof message === "object"), - ) - .map((message) => { - const content = Array.isArray(message.content) - ? message.content.find((part): part is { text: string } => - Boolean(part && typeof part === "object" && typeof part.text === "string"), - )?.text - : typeof message.content === "string" - ? message.content - : undefined; - return { role: message.role, text: content }; - }); -} - -async function createSqliteMirrorTarget(prefix: string, options: { sessionId?: string } = {}) { - const root = await makeRoot(prefix); - const agentId = "main"; - const sessionId = options.sessionId ?? "session-1"; - const sessionKey = `agent:${agentId}:${sessionId}`; - const storePath = path.join(root, "openclaw-agent.sqlite"); - await upsertSessionEntry({ - agentId, - sessionKey, - storePath, - entry: { - sessionFile: `sqlite:${agentId}:${sessionId}:${storePath}`, - sessionId, - updatedAt: 1, - }, - }); - return { - agentId, - sessionId, - sessionKey, - storePath, - bogusSessionFile: path.join(root, "should-not-be-created.jsonl"), - }; -} - -async function readMirrorEvents(target: { - agentId: string; - sessionId: string; - sessionKey: string; - storePath: string; -}): Promise { - return await readSessionTranscriptEvents(target); -} - -async function readMirrorRaw(target: { - agentId: string; - sessionId: string; - sessionKey: string; - storePath: string; -}): Promise { - return (await readMirrorEvents(target)).map((event) => JSON.stringify(event)).join("\n"); -} - -async function readMirrorMessages(target: { - agentId: string; - sessionId: string; - sessionKey: string; - storePath: string; -}): Promise> { - return readEventMessages(await readMirrorEvents(target)); -} - -describe("mirrorCopilotTranscript", () => { - it("hides current memory-maintenance messages without hiding replayed turns", async () => { - initializeGlobalHookRunner( - createMockPluginRegistry([ - { - hookName: "before_message_write", - handler: (event) => { - const { display: _display, ...message } = ( - event as { message: Record } - ).message; - return { message: castAgentMessage(message) }; - }, - }, - ]), - ); - const target = await createSqliteMirrorTarget("openclaw-copilot-mirror-memory-"); - const messages = [ - attachCopilotMirrorIdentity( - makeAgentAssistantMessage({ - content: [{ type: "text", text: "ordinary prior reply" }], - timestamp: Date.now(), - }), - "run-prior:assistant:final", - ), - attachCopilotMirrorIdentity( - makeAgentUserMessage({ - content: [{ type: "text", text: "Pre-compaction memory flush" }], - timestamp: Date.now() + 1, - }), - "run-memory:prompt", - ), - attachCopilotMirrorIdentity( - makeAgentAssistantMessage({ - content: [{ type: "toolCall", id: "call-1", name: "write", arguments: {} }], - timestamp: Date.now() + 2, - }), - "run-memory:tool-call:call-1", - ), - attachCopilotMirrorIdentity( - castAgentMessage({ - role: "toolResult", - toolCallId: "call-1", - toolName: "write", - content: [{ type: "toolResult", toolCallId: "call-1", content: "saved" }], - timestamp: Date.now() + 3, - }), - "run-memory:tool-result:call-1", - ), - attachCopilotMirrorIdentity( - makeAgentAssistantMessage({ - content: [{ type: "text", text: "NO_REPLY" }], - timestamp: Date.now() + 4, - }), - "run-memory:assistant:final", - ), - ]; - for (const message of messages.slice(1)) { - Object.assign(message, { display: false }); - } - - await mirrorCopilotTranscript({ - ...target, - messages, - idempotencyScope: "copilot:memory", - }); - - const persistedMessages = (await readMirrorEvents(target)) - .map((event) => - event && typeof event === "object" ? (event as { message?: unknown }).message : undefined, - ) - .filter((message): message is Record => - Boolean(message && typeof message === "object"), - ); - expect(persistedMessages).toHaveLength(messages.length); - expect(persistedMessages[0]).not.toHaveProperty("display", false); - expect(persistedMessages.slice(1).every((message) => message.display === false)).toBe(true); - }); - - it("mirrors user, assistant, and tool result messages by SQLite identity", async () => { - const target = await createSqliteMirrorTarget("openclaw-copilot-mirror-basic-"); - const userMessage = makeAgentUserMessage({ - content: [{ type: "text", text: "hello" }], - timestamp: Date.now(), - }); - const assistantMessage = makeAgentAssistantMessage({ - content: [{ type: "text", text: "hi there" }], - timestamp: Date.now() + 1, - }); - const toolResultMessage = castAgentMessage({ - role: "toolResult", - toolCallId: "call-1", - toolName: "read", - content: [{ type: "toolResult", toolCallId: "call-1", content: "read output" }], - timestamp: Date.now() + 2, - }) as MirroredAgentMessage; - - await mirrorCopilotTranscript({ - ...target, - messages: [userMessage, assistantMessage, toolResultMessage], - idempotencyScope: "copilot:session-1", - }); - - const raw = await readMirrorRaw(target); - expect(raw).toContain('"role":"user"'); - expect(raw).toContain('"role":"assistant"'); - expect(raw).toContain('"role":"toolResult"'); - expect(raw).toContain('"toolCallId":"call-1"'); - expect(raw).toContain( - `"idempotencyKey":"copilot:session-1:user:${expectedFingerprint(userMessage)}"`, - ); - expect(raw).toContain( - `"idempotencyKey":"copilot:session-1:assistant:${expectedFingerprint(assistantMessage)}"`, - ); - expect(raw).toContain( - `"idempotencyKey":"copilot:session-1:toolResult:${expectedFingerprint(toolResultMessage)}"`, - ); - await expect(fs.readFile(target.bogusSessionFile, "utf8")).rejects.toHaveProperty( - "code", - "ENOENT", - ); - }); - - it("preserves gateway user-turn identity across Copilot transcript mirroring", async () => { - const target = await createSqliteMirrorTarget("openclaw-copilot-mirror-user-identity-"); - const userMessage = castAgentMessage({ - ...makeAgentUserMessage({ - content: [{ type: "text", text: "client prompt" }], - timestamp: Date.now(), - }), - idempotencyKey: "client-run:user", - }); - - await mirrorCopilotTranscript({ - ...target, - messages: [userMessage], - idempotencyScope: "copilot:session-1", - }); - await mirrorCopilotTranscript({ - ...target, - messages: [userMessage], - idempotencyScope: "copilot:session-1", - }); - - const raw = await readMirrorRaw(target); - expect(raw).toContain('"idempotencyKey":"client-run:user"'); - expect(raw).not.toContain('"idempotencyKey":"copilot:session-1:user:'); - expect( - (await readMirrorMessages(target)).filter((message) => message.role === "user"), - ).toHaveLength(1); - }); - - it("deduplicates re-emits by idempotency scope", async () => { - const target = await createSqliteMirrorTarget("openclaw-copilot-mirror-dedupe-"); - const messages = [ - makeAgentUserMessage({ - content: [{ type: "text", text: "hello" }], - timestamp: Date.now(), - }), - makeAgentAssistantMessage({ - content: [{ type: "text", text: "hi there" }], - timestamp: Date.now() + 1, - }), - ] as const; - - await mirrorCopilotTranscript({ - ...target, - messages: [...messages], - idempotencyScope: "copilot:session-1", - }); - await mirrorCopilotTranscript({ - ...target, - messages: [...messages], - idempotencyScope: "copilot:session-1", - }); - - expect((await readMirrorMessages(target)).filter((message) => message.role)).toHaveLength(2); - }); - - it("runs before_message_write before appending mirrored messages", async () => { - initializeGlobalHookRunner( - createMockPluginRegistry([ - { - hookName: "before_message_write", - handler: (event) => ({ - message: castAgentMessage({ - ...((event as { message: unknown }).message as Record), - content: [{ type: "text", text: "hello [hooked]" }], - }), - }), - }, - ]), - ); - const target = await createSqliteMirrorTarget("openclaw-copilot-mirror-hook-"); - const sourceMessage = makeAgentAssistantMessage({ - content: [{ type: "text", text: "hello" }], - timestamp: Date.now(), - }); - - await mirrorCopilotTranscript({ - ...target, - messages: [sourceMessage], - idempotencyScope: "copilot:session-1", - }); - - const raw = await readMirrorRaw(target); - expect(raw).toContain('"content":[{"type":"text","text":"hello [hooked]"}]'); - expect(raw).toContain( - `"idempotencyKey":"copilot:session-1:assistant:${expectedFingerprint(sourceMessage)}"`, - ); - }); - - it("respects before_message_write blocking decisions", async () => { - initializeGlobalHookRunner( - createMockPluginRegistry([ - { hookName: "before_message_write", handler: () => ({ block: true }) }, - ]), - ); - const target = await createSqliteMirrorTarget("openclaw-copilot-mirror-block-"); - - await mirrorCopilotTranscript({ - ...target, - messages: [ - makeAgentAssistantMessage({ - content: [{ type: "text", text: "should not persist" }], - timestamp: Date.now(), - }), - ], - idempotencyScope: "copilot:session-1", - }); - - expect(await readMirrorMessages(target)).toEqual([]); - }); - - it("is a no-op when no mirrorable messages are present", async () => { - const target = await createSqliteMirrorTarget("openclaw-copilot-mirror-empty-"); - - await mirrorCopilotTranscript({ - ...target, - messages: [], - idempotencyScope: "copilot:session-1", - }); - - expect(await readMirrorMessages(target)).toEqual([]); - }); - - it("uses content fingerprint when no explicit mirror identity is attached", async () => { - const target = await createSqliteMirrorTarget("openclaw-copilot-mirror-fingerprint-"); - const message = makeAgentAssistantMessage({ - content: [{ type: "text", text: "fp" }], - timestamp: Date.now(), - }); - - await mirrorCopilotTranscript({ - ...target, - messages: [message], - idempotencyScope: "scope-fp", - }); - - const raw = await readMirrorRaw(target); - expect(raw).toContain(`"idempotencyKey":"scope-fp:assistant:${expectedFingerprint(message)}"`); - }); - - it("uses attached identity instead of content fingerprint when provided", async () => { - const target = await createSqliteMirrorTarget("openclaw-copilot-mirror-identity-"); - const baseMessage = makeAgentAssistantMessage({ - content: [{ type: "text", text: "explicit" }], - timestamp: Date.now(), - }); - const tagged = attachCopilotMirrorIdentity(baseMessage, "sdk-session-1:assistant:0"); - - await mirrorCopilotTranscript({ - ...target, - messages: [tagged], - idempotencyScope: "copilot:openclaw-session-1", - }); - - const raw = await readMirrorRaw(target); - expect(raw).toContain( - '"idempotencyKey":"copilot:openclaw-session-1:sdk-session-1:assistant:0"', - ); - expect(raw).not.toContain(expectedFingerprint(baseMessage)); - }); - - it("omits idempotencyKey when no idempotencyScope is provided", async () => { - const target = await createSqliteMirrorTarget("openclaw-copilot-mirror-no-scope-"); - - await mirrorCopilotTranscript({ - ...target, - messages: [ - makeAgentAssistantMessage({ - content: [{ type: "text", text: "no scope" }], - timestamp: Date.now(), - }), - ], - }); - - const raw = await readMirrorRaw(target); - expect(raw).toContain('"content":[{"type":"text","text":"no scope"}]'); - expect(raw).not.toContain("idempotencyKey"); - }); - - it("filters out non-mirrorable roles", async () => { - const target = await createSqliteMirrorTarget("openclaw-copilot-mirror-filter-"); - const userMessage = makeAgentUserMessage({ - content: [{ type: "text", text: "u" }], - timestamp: Date.now(), - }); - const systemLike = castAgentMessage({ - role: "system" as never, - content: [{ type: "text", text: "system note" }], - timestamp: Date.now() + 1, - }); - - await mirrorCopilotTranscript({ - ...target, - messages: [userMessage, systemLike], - idempotencyScope: "scope", - }); - - const raw = await readMirrorRaw(target); - expect(raw).toContain('"role":"user"'); - expect(raw).not.toContain("system note"); - }); - - it("preserves explicit identity across attachCopilotMirrorIdentity overrides", async () => { - const target = await createSqliteMirrorTarget("openclaw-copilot-mirror-override-"); - const base = makeAgentAssistantMessage({ - content: [{ type: "text", text: "x" }], - timestamp: Date.now(), - }); - const first = attachCopilotMirrorIdentity(base, "id-1"); - const second = attachCopilotMirrorIdentity(first, "id-2"); - - await mirrorCopilotTranscript({ - ...target, - messages: [second], - idempotencyScope: "scope", - }); - - const raw = await readMirrorRaw(target); - expect(raw).toContain('"idempotencyKey":"scope:id-2"'); - expect(raw).not.toContain('"idempotencyKey":"scope:id-1"'); - }); -}); - -describe("dualWriteCopilotTranscriptBestEffort", () => { - it("returns normally when mirror succeeds", async () => { - const target = await createSqliteMirrorTarget("openclaw-copilot-mirror-best-effort-"); - await expect( - dualWriteCopilotTranscriptBestEffort({ - ...target, - messages: [ - makeAgentAssistantMessage({ - content: [{ type: "text", text: "ok" }], - timestamp: Date.now(), - }), - ], - idempotencyScope: "scope", - }), - ).resolves.toBeUndefined(); - expect(await readMirrorMessages(target)).toContainEqual({ role: "assistant", text: "ok" }); - }); - - it("swallows missing runtime identity and does not write JSONL", async () => { - const root = await makeRoot("openclaw-copilot-mirror-invalid-"); - const sessionFile = path.join(root, "agents", "main", "sessions", "session-1.jsonl"); - await expect( - dualWriteCopilotTranscriptBestEffort({ - agentId: "main", - sessionId: "session-1", - messages: [ - makeAgentAssistantMessage({ - content: [{ type: "text", text: "should-not-throw" }], - timestamp: Date.now(), - }), - ], - idempotencyScope: "scope", - }), - ).resolves.toBeUndefined(); - await expect(fs.access(sessionFile)).rejects.toHaveProperty("code", "ENOENT"); - }); -}); diff --git a/extensions/copilot/src/dual-write-transcripts.ts b/extensions/copilot/src/dual-write-transcripts.ts deleted file mode 100755 index 63ba23375c62..000000000000 --- a/extensions/copilot/src/dual-write-transcripts.ts +++ /dev/null @@ -1,247 +0,0 @@ -/** - * Mirrors the AgentMessages produced by the copilot agent runtime into the - * OpenClaw audit transcript that sits next to (but is distinct from) the - * SDK's own session storage. - * - * The OpenClaw shell (src/agents/command/attempt-execution.ts) already - * writes the user prompt and the terminal assistant text into the - * transcript at the end of each attempt. That is the bare minimum to - * keep `/history` working. It does NOT capture tool calls, tool - * results, or intermediate assistant turns — those live only in the - * SDK's own session file. - * - * For audit/compliance and for the codex-parity guarantees we promised - * in the proposal, we mirror the full `messagesSnapshot` (user + - * assistant + toolResult) into the OpenClaw transcript via the same - * plugin-sdk primitives that the codex extension uses - * (extensions/codex/src/app-server/transcript-mirror.ts). Both writers - * cooperate via idempotency-key dedupe: user entries retain a gateway - * turn key when present; other entries use `${idempotencyScope}:${identity}`. - * We skip any key already present in the transcript on disk. Both - * attempt-execution's untagged entries (no idempotencyKey) and our - * tagged mirror entries can coexist; attempt-execution dedupes its own - * final-assistant append via `embeddedAssistantGapFill` content match. - * - * Failures (lock contention, fs errors, etc.) are swallowed by the - * caller-side `dualWriteCopilotTranscriptBestEffort` wrapper used - * in attempt.ts so they cannot break the attempt; this module itself - * throws on infrastructure failure so callers can choose policy. - */ - -import { createHash } from "node:crypto"; -import { - projectAgentHarnessTranscriptMessageForDisplay, - runAgentHarnessBeforeMessageWriteHook, - type AgentMessage, -} from "openclaw/plugin-sdk/agent-harness-runtime"; -import { - withSessionTranscriptWriteLock, - type SessionTranscriptTargetParams, - type SessionTranscriptWriteLockContext, - type SessionTranscriptWriteLockParams, -} from "openclaw/plugin-sdk/session-transcript-runtime"; - -type MirroredAgentMessage = Extract; - -const MIRROR_IDENTITY_META_KEY = "mirrorIdentity" as const; - -/** - * Tag a message with a stable logical identity for mirror dedupe. - * Callers should use a value that is invariant for the same logical - * message across re-emits (e.g. `${sdkSessionId}:assistant:${turnIndex}`) - * but distinct for genuinely-distinct messages. When present this - * identity replaces the role/content fingerprint in the idempotency - * key, so the dedupe survives caller-scope rotation without collapsing - * distinct same-content turns. Symmetric to - * `attachCodexMirrorIdentity` in the codex extension. - */ -export function attachCopilotMirrorIdentity( - message: T, - identity: string, -): T { - const record = message as unknown as Record; - const existing = record["__openclaw"]; - const baseMeta = - existing && typeof existing === "object" && !Array.isArray(existing) - ? (existing as Record) - : {}; - return { - ...record, - __openclaw: { ...baseMeta, [MIRROR_IDENTITY_META_KEY]: identity }, - } as unknown as T; -} - -function readMirrorIdentity(message: MirroredAgentMessage): string | undefined { - const record = message as unknown as { __openclaw?: unknown }; - const meta = record["__openclaw"]; - if (!meta || typeof meta !== "object" || Array.isArray(meta)) { - return undefined; - } - const id = (meta as Record)[MIRROR_IDENTITY_META_KEY]; - return typeof id === "string" && id.length > 0 ? id : undefined; -} - -function fingerprintMirrorMessageContent(message: MirroredAgentMessage): string { - const payload = JSON.stringify({ role: message.role, content: message.content }); - return createHash("sha256").update(payload).digest("hex").slice(0, 16); -} - -function buildMirrorDedupeIdentity(message: MirroredAgentMessage): string { - const explicit = readMirrorIdentity(message); - if (explicit) { - return explicit; - } - return `${message.role}:${fingerprintMirrorMessageContent(message)}`; -} - -interface MirrorCopilotTranscriptParams { - sessionId: string; - sessionKey?: string; - agentId?: string; - storePath?: string; - messages: AgentMessage[]; - /** - * Stable per-harness/per-thread scope. The codex equivalent uses - * `codex-app-server:${threadId}`; we use `copilot:${sessionId}` - * by convention (see attempt.ts call site). Keeping the scope - * thread-stable (not per-turn) is what lets a re-emitted prior-turn - * entry collide with its existing on-disk key and be a true no-op. - */ - idempotencyScope?: string; - config?: SessionTranscriptWriteLockParams["config"]; -} - -async function mirrorCopilotTranscript(params: MirrorCopilotTranscriptParams): Promise { - const messages = params.messages.filter( - (message): message is MirroredAgentMessage => - message.role === "user" || message.role === "assistant" || message.role === "toolResult", - ); - if (messages.length === 0) { - return; - } - - const transcriptTarget = resolveCopilotMirrorTranscriptTarget(params); - await withCopilotMirrorTranscriptWriteLock( - { ...transcriptTarget, config: params.config }, - async (transcript) => { - let didAppendMessage = false; - const existingIdempotencyKeys = readTranscriptIdempotencyKeys(await transcript.readEvents()); - for (const message of messages) { - const dedupeIdentity = buildMirrorDedupeIdentity(message); - const sourceIdempotencyKey = (message as unknown as { idempotencyKey?: unknown }) - .idempotencyKey; - const sourceUserIdempotencyKey = - message.role === "user" && - typeof sourceIdempotencyKey === "string" && - sourceIdempotencyKey.trim() - ? sourceIdempotencyKey.trim() - : undefined; - const idempotencyKey = - sourceUserIdempotencyKey ?? - (params.idempotencyScope ? `${params.idempotencyScope}:${dedupeIdentity}` : undefined); - if (idempotencyKey && existingIdempotencyKeys.has(idempotencyKey)) { - continue; - } - const transcriptMessage = { - ...message, - ...(idempotencyKey ? { idempotencyKey } : {}), - } as AgentMessage; - const nextMessage = runAgentHarnessBeforeMessageWriteHook({ - message: transcriptMessage, - agentId: params.agentId, - sessionKey: params.sessionKey, - }); - if (!nextMessage) { - continue; - } - const messageWithIdentity = ( - idempotencyKey - ? { - ...(nextMessage as unknown as Record), - idempotencyKey, - } - : nextMessage - ) as AgentMessage; - const messageToAppend = projectAgentHarnessTranscriptMessageForDisplay({ - hidden: (message as { display?: boolean }).display === false, - message: messageWithIdentity, - }); - const appended = await transcript.appendMessage({ - message: messageToAppend, - idempotencyLookup: idempotencyKey ? "caller-checked" : "scan", - }); - if (!appended) { - continue; - } - didAppendMessage = true; - if (idempotencyKey) { - existingIdempotencyKeys.add(idempotencyKey); - } - } - if (didAppendMessage) { - await transcript.publishUpdate( - params.sessionKey ? { sessionKey: params.sessionKey } : undefined, - ); - } - return didAppendMessage; - }, - ); -} - -function resolveCopilotMirrorTranscriptTarget(params: { - agentId?: string; - sessionId: string; - sessionKey?: string; - storePath?: string; -}): SessionTranscriptTargetParams { - const sessionKey = params.sessionKey?.trim(); - const storePath = params.storePath?.trim(); - if (!sessionKey || !storePath) { - throw new Error("Copilot transcript mirror requires a runtime session identity"); - } - return { - ...(params.agentId ? { agentId: params.agentId } : {}), - sessionId: params.sessionId, - sessionKey, - storePath, - }; -} - -function withCopilotMirrorTranscriptWriteLock( - params: SessionTranscriptTargetParams & { config?: SessionTranscriptWriteLockParams["config"] }, - run: (context: SessionTranscriptWriteLockContext) => Promise | T, -): Promise { - return withSessionTranscriptWriteLock(params, run); -} - -function readTranscriptIdempotencyKeys(events: unknown[]): Set { - const keys = new Set(); - for (const event of events) { - if (!event || typeof event !== "object" || Array.isArray(event)) { - continue; - } - const parsed = event as { message?: { idempotencyKey?: unknown } }; - if (typeof parsed.message?.idempotencyKey === "string") { - keys.add(parsed.message.idempotencyKey); - } - } - return keys; -} - -/** - * Caller-side wrapper that swallows mirror failures. attempt.ts uses - * this so that a transient transcript-mirror failure (lock contention, - * disk full, etc.) never breaks an otherwise-successful attempt. The - * SDK's own session file remains the source of truth in that case; - * the OpenClaw audit trail just misses the intermediate messages for - * this turn. - */ -export async function dualWriteCopilotTranscriptBestEffort( - params: MirrorCopilotTranscriptParams, -): Promise { - try { - await mirrorCopilotTranscript(params); - } catch (error) { - console.warn("[copilot-attempt] dual-write transcript mirror failed", error); - } -} diff --git a/extensions/copilot/src/event-bridge-transcript.ts b/extensions/copilot/src/event-bridge-transcript.ts new file mode 100644 index 000000000000..e363a78462b5 --- /dev/null +++ b/extensions/copilot/src/event-bridge-transcript.ts @@ -0,0 +1,167 @@ +import type { Attachment, SessionEvent } from "@github/copilot-sdk"; +import type { AgentMessage } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { sanitizeToolResult } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { buildCopilotAssistantUsage, type CopilotUsageSnapshot } from "./usage-bridge.js"; + +export type AssistantMessage = Extract; +export type AssistantUsageSnapshot = CopilotUsageSnapshot; + +export interface AttemptTranscriptJournalProjection { + markReplayIncomplete(): void; + recordAssistantProjectionGap(): void; + recordAssistant(input: { + eventId: string; + message: AssistantMessage; + replayIncomplete?: boolean; + toolCallIds: string[]; + }): void; + recordSdkUser(input: { + autopilotContinuation: boolean; + eventId: string; + message: Extract; + replayIncomplete?: boolean; + }): void; + recordToolResult(input: { + eventId: string; + message: Extract; + replayIncomplete?: boolean; + }): void; +} + +export function buildAssistantMessage(params: { + assistantTexts: string[]; + event?: Extract; + modelRef: { api?: string; id: string; provider: string }; + now: () => number; + reasoningText?: string; + usage?: AssistantUsageSnapshot; +}): AssistantMessage | undefined { + const event = params.event; + const text = event ? event.data.content || params.assistantTexts.at(-1) || "" : ""; + const reasoningText = event?.data.reasoningText ?? params.reasoningText; + const toolRequests = event?.data.toolRequests ?? []; + if (!text && !reasoningText && toolRequests.length === 0) { + return undefined; + } + const content: AssistantMessage["content"] = []; + if (reasoningText) { + content.push({ thinking: reasoningText, type: "thinking" }); + } + if (text) { + content.push({ text, type: "text" }); + } + for (const request of toolRequests) { + content.push({ + arguments: request.arguments ?? {}, + id: request.toolCallId, + name: request.name, + type: "toolCall", + }); + } + return { + api: params.modelRef.api ?? "openai-responses", + content, + model: event?.data.model ?? params.modelRef.id, + provider: params.modelRef.provider, + role: "assistant", + stopReason: toolRequests.length > 0 ? "toolUse" : "stop", + timestamp: params.now(), + usage: buildCopilotAssistantUsage({ + fallbackOutputTokens: event?.data.outputTokens, + usage: params.usage, + }), + }; +} + +export function resolveAssistantUsage( + event: Extract | undefined, + latest: AssistantUsageSnapshot | undefined, + byApiCallId: Map, +): AssistantUsageSnapshot | undefined { + const apiCallId = readString(event?.data.apiCallId); + return apiCallId ? (byApiCallId.get(apiCallId) ?? latest) : latest; +} + +export function resolveEventTimestamp(timestamp: string, now: () => number): number { + const parsed = Date.parse(timestamp); + return Number.isFinite(parsed) ? parsed : now(); +} + +export function hasOwnKeys(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && Object.keys(value).length > 0); +} + +export function projectSdkUserMetadata( + attachments: Attachment[] | undefined, + source: string | undefined, +): Record | undefined { + const summaries = (attachments ?? []).map((attachment) => { + const { + data: _data, + payload: _payload, + text: _text, + ...summary + } = attachment as Attachment & { + data?: unknown; + payload?: unknown; + text?: unknown; + }; + return summary; + }); + const media = (attachments ?? []).flatMap<{ + contentType?: string; + kind?: string; + path: string; + }>((attachment) => { + if (attachment.type === "file") { + return [{ path: attachment.path, contentType: attachment.mimeType }]; + } + return attachment.type === "selection" ? [{ path: attachment.filePath, kind: "document" }] : []; + }); + if (!source && summaries.length === 0) { + return undefined; + } + return { + ...(source ? { copilotSource: source } : {}), + ...(summaries.length > 0 ? { copilotAttachments: summaries } : {}), + ...(media.length > 0 ? { media } : {}), + }; +} + +export function projectToolResultDetails( + data: Extract["data"], +): unknown { + const result = data.result; + const sanitizedContents = result?.contents + ? (sanitizeToolResult({ content: result.contents }) as { content?: unknown }).content + : undefined; + const binaryResultsForLlm = result?.binaryResultsForLlm?.map((entry) => { + const { data: _data, ...descriptor } = entry as typeof entry & { data?: unknown }; + return descriptor; + }); + const citableSources = result?.citableSources?.map((source) => + Object.assign({}, source, { content: sanitizeToolDetailText(source.content) }), + ); + return sanitizeToolResult({ + ...(result?.detailedContent + ? { content: [{ type: "text", text: result.detailedContent }] } + : {}), + ...(result?.structuredContent ? { structuredContent: result.structuredContent } : {}), + ...(sanitizedContents ? { contents: sanitizedContents } : {}), + ...(binaryResultsForLlm?.length ? { binaryResultsForLlm } : {}), + ...(citableSources?.length ? { citableSources } : {}), + ...(data.mcpMeta || result?.mcpMeta ? { mcpMeta: data.mcpMeta ?? result?.mcpMeta } : {}), + }); +} + +export function sanitizeToolDetailText(text: string): string { + const sanitized = sanitizeToolResult({ content: [{ type: "text", text }] }) as { + content?: Array<{ text?: unknown }>; + }; + const value = sanitized.content?.[0]?.text; + return typeof value === "string" ? value : ""; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} diff --git a/extensions/copilot/src/event-bridge.test.ts b/extensions/copilot/src/event-bridge.test.ts index d7e0d78d62cb..c624ccca0bd1 100644 --- a/extensions/copilot/src/event-bridge.test.ts +++ b/extensions/copilot/src/event-bridge.test.ts @@ -10,10 +10,17 @@ const MODEL_REF = { provider: "github-copilot", } as const; const REGISTERED_EVENT_TYPES = [ + "user.message", + "system.message", + "skill.invoked", + "system.notification", "assistant.message_delta", "assistant.reasoning_delta", + "assistant.reasoning", + "assistant.turn_start", "assistant.message", "assistant.usage", + "tool.user_requested", "tool.execution_start", "tool.execution_complete", "session.plan_changed", @@ -188,6 +195,12 @@ describe("attachEventBridge", () => { expect(bridge.snapshot().assistantTexts).toEqual(["root"]); expect(bridge.snapshot().startedCount).toBe(0); expect(bridge.snapshot().toolMetas).toEqual([{ meta: "child write", toolName: "write" }]); + expect( + bridge.recordSendResult({ + ...makeAssistantMessageEvent("child final"), + agentId: "child-1", + } as SessionEvent), + ).toBe(false); await bridge.awaitDeltaChain(); expect(onAssistantDelta).toHaveBeenCalledTimes(1); }); @@ -215,6 +228,42 @@ describe("attachEventBridge", () => { expect(bridge.snapshot().assistantTexts).toEqual(["ab", "x"]); }); + it("ignored child and ephemeral users do not split a root assistant API call", () => { + const session = createFakeSession(); + const bridge = attachEventBridge(session, { + getSdkSessionId: () => "sdk-session-id", + isAborted: () => false, + }); + + session.emit("assistant.message", { + ...makeAssistantMessageEvent("first", { + apiCallId: "shared-call", + messageId: "chunk-a", + }), + id: "assistant-chunk-a", + } as SessionEvent); + session.emit("user.message", { + ...makeEvent("user.message", { content: "child" }), + agentId: "child-1", + } as SessionEvent); + session.emit("user.message", { + ...makeEvent("user.message", { content: "ephemeral" }), + ephemeral: true, + } as SessionEvent); + session.emit("assistant.message", { + ...makeAssistantMessageEvent("second", { + apiCallId: "shared-call", + messageId: "chunk-b", + }), + id: "assistant-chunk-b", + } as SessionEvent); + bridge.flushTranscriptProjection(); + + expect(bridge.buildAssistantMessage({ modelRef: MODEL_REF, now: () => 9 })?.content).toEqual([ + { type: "text", text: "firstsecond" }, + ]); + }); + it("onAssistantDelta receives appended text, live sessionId, and current usage", async () => { const session = createFakeSession(); let sdkSessionId = "sdk-session-1"; @@ -411,6 +460,27 @@ describe("attachEventBridge", () => { expect(longerBridge.finalizeAssistantTexts()).toEqual(["longer text"]); }); + it("does not let an ephemeral assistant replace the final root response", () => { + const session = createFakeSession(); + const bridge = attachEventBridge(session, { + getSdkSessionId: () => "sdk-session-id", + isAborted: () => false, + }); + const persisted = makeAssistantMessageEvent("persisted final"); + session.emit("assistant.message", persisted); + + expect( + bridge.recordSendResult({ + ...makeAssistantMessageEvent("ephemeral final"), + ephemeral: true, + id: "ephemeral-final", + } as SessionEvent), + ).toBe(false); + expect(bridge.buildAssistantMessage({ modelRef: MODEL_REF, now: () => 9 })?.content).toEqual([ + { text: "persisted final", type: "text" }, + ]); + }); + it("assistant.message with toolRequests produces toolCall content and toolUse stopReason", () => { const session = createFakeSession(); const bridge = attachEventBridge(session, { @@ -605,7 +675,9 @@ describe("attachEventBridge", () => { isAborted: () => false, }); - bridge.recordSendResult(makeAssistantMessageEvent("done", { outputTokens: 7 })); + bridge.recordSendResult( + makeAssistantMessageEvent("done", { apiCallId: "usage-without-id", outputTokens: 7 }), + ); session.emit( "assistant.usage", makeEvent("assistant.usage", { @@ -1042,6 +1114,41 @@ describe("attachEventBridge", () => { expect(bridge.buildAssistantMessage({ modelRef: MODEL_REF, now: () => 13 })).toBeUndefined(); }); + it("keeps ephemeral deltas live without folding their text into the terminal message", async () => { + const session = createFakeSession(); + const onAssistantDelta = vi.fn(); + const bridge = attachEventBridge(session, { + getSdkSessionId: () => "sdk-session-id", + isAborted: () => false, + onAssistantDelta, + }); + + session.emit("assistant.message_delta", { + ...makeEvent("assistant.message_delta", { + deltaContent: "hidden text", + messageId: "ephemeral-message", + }), + ephemeral: true, + } as SessionEvent); + session.emit("assistant.reasoning_delta", { + ...makeEvent("assistant.reasoning_delta", { + deltaContent: "hidden reasoning", + reasoningId: "ephemeral-reasoning", + }), + ephemeral: true, + } as SessionEvent); + bridge.recordSendResult(makeAssistantMessageEvent("visible")); + await bridge.awaitDeltaChain(); + + expect(onAssistantDelta).toHaveBeenCalledWith( + expect.objectContaining({ delta: "hidden text", text: "hidden text" }), + ); + expect(bridge.buildAssistantMessage({ modelRef: MODEL_REF, now: () => 13 })?.content).toEqual([ + { type: "thinking", thinking: "hidden reasoning" }, + { type: "text", text: "visible" }, + ]); + }); + it("detach is idempotent after the first unsubscribe pass", () => { const order: string[] = []; const session = createFakeSession({ diff --git a/extensions/copilot/src/event-bridge.ts b/extensions/copilot/src/event-bridge.ts index 75e2d9be7378..d735353ed371 100644 --- a/extensions/copilot/src/event-bridge.ts +++ b/extensions/copilot/src/event-bridge.ts @@ -5,14 +5,20 @@ import type { AgentMessage, } from "openclaw/plugin-sdk/agent-harness-runtime"; import { - buildCopilotAssistantUsage, - normalizeCopilotUsage, - type CopilotUsageSnapshot, -} from "./usage-bridge.js"; + buildAssistantMessage, + hasOwnKeys, + projectSdkUserMetadata, + projectToolResultDetails, + resolveAssistantUsage, + resolveEventTimestamp, + sanitizeToolDetailText, + type AssistantMessage, + type AssistantUsageSnapshot, + type AttemptTranscriptJournalProjection, +} from "./event-bridge-transcript.js"; +import { normalizeCopilotUsage } from "./usage-bridge.js"; -export type AssistantMessage = Extract; - -export type AssistantUsageSnapshot = CopilotUsageSnapshot; +export type { AssistantMessage, AssistantUsageSnapshot } from "./event-bridge-transcript.js"; export interface OnAssistantDeltaPayload { delta: string; @@ -62,6 +68,11 @@ interface EventBridgeOptions { onContextCompacted?: () => void; getSdkSessionId: () => string | undefined; isAborted: () => boolean; + transcriptProjection?: { + journal: AttemptTranscriptJournalProjection; + modelRef: { api?: string; id: string; provider: string }; + now: () => number; + }; } interface EventBridgeSnapshot { @@ -87,6 +98,7 @@ interface EventBridgeController { settleCompactionWait(): void; awaitDeltaChain(): Promise; awaitAgentEventChain(): Promise; + flushTranscriptProjection(): void; hasObservedCompaction(): boolean; hasObservedSessionIdle(): boolean; isCompacting(): boolean; @@ -97,6 +109,17 @@ interface EventBridgeController { } type MessageAccumulator = { messageId: string; text: string }; +type AssistantProjectionChunk = { + assistantTexts: string[]; + event: Extract; + reasoningText?: string; + transcriptAssistantTexts: string[]; + transcriptReasoningText?: string; +}; +type AssistantProjectionGroup = { + apiCallId?: string; + chunks: AssistantProjectionChunk[]; +}; type PromptErrorWithCode = Error & { code?: string; cause?: unknown }; export function attachEventBridge( @@ -107,11 +130,21 @@ export function attachEventBridge( const messagesById = new Map(); const reasoningOrder: string[] = []; const reasoningById = new Map(); + const durableReasoningOrder: string[] = []; + const durableReasoningById = new Map(); let lastAssistantEvent: Extract | undefined; + let lastAssistantReasoningText: string | undefined; let usage: AssistantUsageSnapshot | undefined; + const usageByApiCallId = new Map(); + const handledAssistantEventIds = new Set(); + const projectedAssistantMessageIdsWithoutApiCall = new Set(); + let pendingAssistantProjection: AssistantProjectionGroup | undefined; + let lastAssistantProjection: AssistantProjectionGroup | undefined; let streamError: Error | undefined; const toolMetas: AgentHarnessAttemptResult["toolMetas"] = []; const toolMetaIndexByCallId = new Map(); + const projectedToolNamesByCallId = new Map(); + const userRequestedToolCallIds = new Set(); let startedCount = 0; let completedCount = 0; let activeCompactionCount = 0; @@ -129,8 +162,66 @@ export function attachEventBridge( }); let firstDeltaError: unknown; let detached = false; + let unconsumedDurableReasoning = false; const unsubscribeFns: Array<() => void> = []; + registerListener(session, unsubscribeFns, "user.message", (event) => { + if (!isRootSessionEvent(event) || event.ephemeral === true) { + return; + } + flushPendingAssistantProjection(); + const projection = options.transcriptProjection; + if (!projection) { + return; + } + const source = readString(event.data.source); + const transformedContent = + typeof event.data.transformedContent === "string" ? event.data.transformedContent : undefined; + const openClawMeta = projectSdkUserMetadata(event.data.attachments, source); + const idempotencyKey = `copilot-sdk:${options.getSdkSessionId() ?? "unknown"}:${event.id}`; + // `source` is open-ended provenance, not a visibility enum. Hide the one + // documented injected source; unknown sources stay visible without guessing. + const hidden = event.data.isAutopilotContinuation === true || source === "skill-pdf"; + projection.journal.recordSdkUser({ + eventId: event.id, + autopilotContinuation: event.data.isAutopilotContinuation === true, + replayIncomplete: Boolean( + event.data.attachments?.length || + (event.data.agentMode !== undefined && event.data.agentMode !== "interactive") || + (transformedContent !== undefined && transformedContent !== event.data.content), + ), + message: { + role: "user", + content: event.data.content, + timestamp: resolveEventTimestamp(event.timestamp, projection.now), + idempotencyKey, + ...(hidden ? { display: false } : {}), + ...(openClawMeta ? { __openclaw: openClawMeta } : {}), + } as Extract, + }); + }); + + registerListener(session, unsubscribeFns, "system.message", (event) => { + if (!isRootSessionEvent(event) || event.ephemeral === true) { + return; + } + // System/developer prompts affect native history but AgentMessage has no + // lossless canonical role for them, so keep native replay fail-closed. + options.transcriptProjection?.journal.markReplayIncomplete(); + }); + + registerListener(session, unsubscribeFns, "skill.invoked", (event) => { + if (isRootSessionEvent(event) && event.ephemeral !== true) { + options.transcriptProjection?.journal.markReplayIncomplete(); + } + }); + + registerListener(session, unsubscribeFns, "system.notification", (event) => { + if (isRootSessionEvent(event) && event.ephemeral !== true) { + options.transcriptProjection?.journal.markReplayIncomplete(); + } + }); + registerListener(session, unsubscribeFns, "assistant.message_delta", (event) => { if (!isRootSessionEvent(event)) { return; @@ -184,15 +275,32 @@ export function attachEventBridge( reasoningById.set(reasoningId, `${reasoningById.get(reasoningId) ?? ""}${delta}`); }); - registerListener(session, unsubscribeFns, "assistant.message", (event) => { - if (!isRootSessionEvent(event)) { + registerListener(session, unsubscribeFns, "assistant.reasoning", (event) => { + if (!isRootSessionEvent(event) || event.ephemeral === true) { return; } - lastAssistantEvent = event; - const entry = ensureMessageAccumulator(messagesById, messageOrder, event.data.messageId); - if (typeof event.data.content === "string" && event.data.content.length >= entry.text.length) { - entry.text = event.data.content; + if (!reasoningById.has(event.data.reasoningId)) { + reasoningOrder.push(event.data.reasoningId); } + reasoningById.set(event.data.reasoningId, event.data.content); + if (!durableReasoningById.has(event.data.reasoningId)) { + durableReasoningOrder.push(event.data.reasoningId); + } + durableReasoningById.set(event.data.reasoningId, event.data.content); + unconsumedDurableReasoning = true; + }); + + registerListener(session, unsubscribeFns, "assistant.turn_start", (event) => { + if (isRootSessionEvent(event)) { + markUnconsumedReasoningIncomplete(); + } + }); + + registerListener(session, unsubscribeFns, "assistant.message", (event) => { + if (!isRootSessionEvent(event) || event.ephemeral === true) { + return; + } + handleAssistantMessage(event); }); registerListener(session, unsubscribeFns, "assistant.usage", (event) => { @@ -200,9 +308,24 @@ export function attachEventBridge( return; } usage = normalizeCopilotUsage(event.data); + const apiCallId = readString(event.data.apiCallId); + if (apiCallId && usage) { + usageByApiCallId.set(apiCallId, usage); + } + if (apiCallId) { + flushPendingAssistantProjection(apiCallId); + } + }); + + registerListener(session, unsubscribeFns, "tool.user_requested", (event) => { + if (isRootSessionEvent(event) && event.ephemeral !== true) { + userRequestedToolCallIds.add(event.data.toolCallId); + options.transcriptProjection?.journal.markReplayIncomplete(); + } }); registerListener(session, unsubscribeFns, "tool.execution_start", (event) => { + flushPendingAssistantProjectionForToolCall(event.data.toolCallId); if (isRootSessionEvent(event)) { startedCount += 1; } @@ -211,6 +334,7 @@ export function attachEventBridge( }); registerListener(session, unsubscribeFns, "tool.execution_complete", (event) => { + flushPendingAssistantProjectionForToolCall(event.data.toolCallId); if (isRootSessionEvent(event)) { completedCount += 1; } @@ -226,6 +350,40 @@ export function attachEventBridge( ...(event.data.success ? {} : { isError: true }), }; } + const projection = options.transcriptProjection; + const isDurableRootCompletion = isRootSessionEvent(event) && event.ephemeral !== true; + const wasTrackedUserRequest = userRequestedToolCallIds.has(event.data.toolCallId); + const isUserRequested = event.data.isUserRequested === true || wasTrackedUserRequest; + const projectedToolName = projectedToolNamesByCallId.get(event.data.toolCallId); + if (isDurableRootCompletion) { + userRequestedToolCallIds.delete(event.data.toolCallId); + projectedToolNamesByCallId.delete(event.data.toolCallId); + } + if (projection && isDurableRootCompletion && isUserRequested) { + projection.journal.markReplayIncomplete(); + } + if (projection && isDurableRootCompletion && !isUserRequested) { + const resultText = event.data.success + ? (event.data.result?.content ?? "") + : (event.data.error?.message ?? "Tool execution failed"); + const details = projectToolResultDetails(event.data); + const replayIncomplete = Boolean( + event.data.result?.binaryResultsForLlm?.length || event.data.result?.citableSources?.length, + ); + projection.journal.recordToolResult({ + eventId: event.id, + replayIncomplete, + message: { + role: "toolResult", + toolCallId: event.data.toolCallId, + toolName: toolName ?? event.data.toolDescription?.name ?? projectedToolName ?? "unknown", + content: [{ type: "text", text: sanitizeToolDetailText(resultText) }], + ...(hasOwnKeys(details) ? { details } : {}), + isError: !event.data.success, + timestamp: resolveEventTimestamp(event.timestamp, projection.now), + }, + }); + } }); registerListener(session, unsubscribeFns, "session.plan_changed", (event) => { @@ -341,12 +499,15 @@ export function attachEventBridge( if (!isRootCompactionEvent(event)) { return; } + markUnconsumedReasoningIncomplete(); + flushPendingAssistantProjection(); observedSessionIdle = true; resolveSessionIdle?.(); resolveSessionIdle = undefined; }); registerListener(session, unsubscribeFns, "session.error", (event) => { + markUnconsumedReasoningIncomplete(); if (!options.isAborted()) { streamError = createPromptError( event.data.errorCode ?? event.data.errorType, @@ -356,6 +517,7 @@ export function attachEventBridge( }); registerListener(session, unsubscribeFns, "abort", (event) => { + markUnconsumedReasoningIncomplete(); if (!options.isAborted()) { streamError = createPromptError( "session_aborted", @@ -366,10 +528,15 @@ export function attachEventBridge( return { recordSendResult(result) { - if (!isAssistantMessageEvent(result)) { + if ( + !isAssistantMessageEvent(result) || + !isRootSessionEvent(result) || + result.ephemeral === true + ) { return false; } - lastAssistantEvent = result; + handleAssistantMessage(result); + flushPendingAssistantProjection(); return true; }, awaitCompactionChain() { @@ -392,6 +559,10 @@ export function attachEventBridge( awaitAgentEventChain() { return agentEventChain; }, + flushTranscriptProjection() { + markUnconsumedReasoningIncomplete(); + flushPendingAssistantProjection(); + }, hasObservedCompaction() { return observedCompaction; }, @@ -413,15 +584,24 @@ export function attachEventBridge( }; }, buildAssistantMessage(args) { - return buildAssistantMessage({ - event: lastAssistantEvent, - modelRef: args.modelRef, - now: args.now, - reasoningById, - reasoningOrder, - usage, - assistantTexts: finalizeAssistantTexts(messageOrder, messagesById, lastAssistantEvent), - }); + const group = pendingAssistantProjection ?? lastAssistantProjection; + return group + ? buildAssistantProjectionGroup( + group, + args.modelRef, + () => args.now(), + usageByApiCallId, + usage, + false, + ).message + : buildAssistantMessage({ + event: lastAssistantEvent, + modelRef: args.modelRef, + now: args.now, + reasoningText: lastAssistantReasoningText, + usage: resolveAssistantUsage(lastAssistantEvent, usage, usageByApiCallId), + assistantTexts: finalizeAssistantTexts(messageOrder, messagesById, lastAssistantEvent), + }); }, finalizeAssistantTexts() { return finalizeAssistantTexts(messageOrder, messagesById, lastAssistantEvent); @@ -442,6 +622,135 @@ export function attachEventBridge( }, }; + function handleAssistantMessage( + event: Extract, + ): void { + if (!isRootSessionEvent(event) || event.ephemeral === true) { + return; + } + lastAssistantEvent = event; + if (handledAssistantEventIds.has(event.id)) { + return; + } + handledAssistantEventIds.add(event.id); + for (const request of event.data.toolRequests ?? []) { + projectedToolNamesByCallId.set(request.toolCallId, request.name); + } + const entry = ensureMessageAccumulator(messagesById, messageOrder, event.data.messageId); + if (typeof event.data.content === "string" && event.data.content.length >= entry.text.length) { + entry.text = event.data.content; + } + lastAssistantReasoningText = + event.data.reasoningText ?? (joinReasoning(reasoningOrder, reasoningById) || undefined); + const transcriptReasoningText = + event.data.reasoningText ?? + (joinReasoning(durableReasoningOrder, durableReasoningById) || undefined); + reasoningOrder.length = 0; + reasoningById.clear(); + durableReasoningOrder.length = 0; + durableReasoningById.clear(); + unconsumedDurableReasoning = false; + const chunk: AssistantProjectionChunk = { + event, + assistantTexts: [messagesById.get(event.data.messageId)?.text ?? ""], + ...(lastAssistantReasoningText ? { reasoningText: lastAssistantReasoningText } : {}), + transcriptAssistantTexts: [event.data.content ?? ""], + ...(transcriptReasoningText ? { transcriptReasoningText } : {}), + }; + const apiCallId = readString(event.data.apiCallId); + if (!apiCallId) { + if (projectedAssistantMessageIdsWithoutApiCall.has(event.data.messageId)) { + options.transcriptProjection?.journal.markReplayIncomplete(); + lastAssistantProjection = { chunks: [chunk] }; + return; + } + projectedAssistantMessageIdsWithoutApiCall.add(event.data.messageId); + flushPendingAssistantProjection(); + const group = { chunks: [chunk] } satisfies AssistantProjectionGroup; + lastAssistantProjection = group; + recordAssistantProjection(group); + return; + } + if (pendingAssistantProjection?.apiCallId !== apiCallId) { + flushPendingAssistantProjection(); + pendingAssistantProjection = { apiCallId, chunks: [] }; + } + const priorChunkIndex = pendingAssistantProjection.chunks.findIndex( + (candidate) => candidate.event.data.messageId === event.data.messageId, + ); + if (priorChunkIndex === -1) { + pendingAssistantProjection.chunks.push(chunk); + } else { + pendingAssistantProjection.chunks[priorChunkIndex] = chunk; + } + } + + function flushPendingAssistantProjection(apiCallId?: string): void { + const group = pendingAssistantProjection; + if (!group || (apiCallId !== undefined && group.apiCallId !== apiCallId)) { + return; + } + pendingAssistantProjection = undefined; + lastAssistantProjection = group; + recordAssistantProjection(group); + } + + function markUnconsumedReasoningIncomplete(): void { + if (unconsumedDurableReasoning) { + options.transcriptProjection?.journal.markReplayIncomplete(); + } + reasoningOrder.length = 0; + reasoningById.clear(); + durableReasoningOrder.length = 0; + durableReasoningById.clear(); + unconsumedDurableReasoning = false; + } + + function flushPendingAssistantProjectionForToolCall(toolCallId: string): void { + const group = pendingAssistantProjection; + if ( + group?.chunks.some((chunk) => + chunk.event.data.toolRequests?.some((request) => request.toolCallId === toolCallId), + ) + ) { + flushPendingAssistantProjection(); + } + } + + function recordAssistantProjection(group: AssistantProjectionGroup): void { + const projection = options.transcriptProjection; + if (!projection) { + return; + } + const { message, replayIncomplete, toolCallIds } = buildAssistantProjectionGroup( + group, + projection.modelRef, + (event) => resolveEventTimestamp(event.timestamp, projection.now), + usageByApiCallId, + // Optional assistant.usage must never delay canonical content. Later + // unkeyed usage stays attempt metadata; this row uses per-call data only. + undefined, + true, + ); + const eventId = group.chunks[0]?.event.id; + if (!eventId) { + return; + } + if (!message) { + if (replayIncomplete) { + projection.journal.markReplayIncomplete(); + } + projection.journal.recordAssistantProjectionGap(); + return; + } + projection.journal.recordAssistant({ + eventId, + message, + replayIncomplete, + toolCallIds, + }); + } + function enqueueCompactionCallback(callback: (() => void | Promise) | undefined): void { if (!callback) { return; @@ -490,57 +799,6 @@ export function attachEventBridge( } } -function buildAssistantMessage(params: { - assistantTexts: string[]; - event?: Extract; - modelRef: { api?: string; id: string; provider: string }; - now: () => number; - reasoningById: Map; - reasoningOrder: string[]; - usage?: AssistantUsageSnapshot; -}): AssistantMessage | undefined { - const event = params.event; - const text = event - ? event.data.content || params.assistantTexts[params.assistantTexts.length - 1] || "" - : ""; - const reasoningText = - event?.data.reasoningText ?? joinReasoning(params.reasoningOrder, params.reasoningById); - const toolRequests = event?.data.toolRequests ?? []; - if (!text && !reasoningText && toolRequests.length === 0) { - return undefined; - } - - const content: AssistantMessage["content"] = []; - if (reasoningText) { - content.push({ thinking: reasoningText, type: "thinking" }); - } - if (text) { - content.push({ text, type: "text" }); - } - for (const request of toolRequests) { - content.push({ - arguments: request.arguments ?? {}, - id: request.toolCallId, - name: request.name, - type: "toolCall", - }); - } - - return { - api: params.modelRef.api ?? "openai-responses", - content, - model: event?.data.model ?? params.modelRef.id, - provider: params.modelRef.provider, - role: "assistant", - stopReason: toolRequests.length > 0 ? "toolUse" : "stop", - timestamp: params.now(), - usage: buildCopilotAssistantUsage({ - fallbackOutputTokens: event?.data.outputTokens, - usage: params.usage, - }), - }; -} - function createPromptError(code: string, message: string, cause?: unknown): PromptErrorWithCode { const error = new Error(message) as PromptErrorWithCode; error.code = code; @@ -581,6 +839,102 @@ function finalizeAssistantTexts( return []; } +function buildAssistantProjectionGroup( + group: AssistantProjectionGroup, + modelRef: { api?: string; id: string; provider: string }, + resolveTimestamp: (event: Extract) => number, + usageByApiCallId: Map, + latestUsage: AssistantUsageSnapshot | undefined, + forTranscript: boolean, +): { + message: AssistantMessage | undefined; + replayIncomplete: boolean; + toolCallIds: string[]; +} { + const messages = group.chunks.flatMap((chunk) => { + const message = buildAssistantMessage({ + event: chunk.event, + modelRef, + now: () => resolveTimestamp(chunk.event), + reasoningText: forTranscript ? chunk.transcriptReasoningText : chunk.reasoningText, + // Usage is keyed to the complete API call, so every chunk resolves to the + // same snapshot and the merged message keeps the terminal copy. + usage: resolveAssistantUsage(chunk.event, latestUsage, usageByApiCallId), + assistantTexts: forTranscript ? chunk.transcriptAssistantTexts : chunk.assistantTexts, + }); + return message ? [message] : []; + }); + const replayIncomplete = group.chunks.some(({ event }) => + hasUnprojectedAssistantReplayState(event), + ); + const last = messages.at(-1); + if (!last) { + return { message: undefined, replayIncomplete, toolCallIds: [] }; + } + const narrative: AssistantMessage["content"] = []; + let terminalThinking: + | Extract + | undefined; + const toolCallOrder: string[] = []; + const toolCallsById = new Map< + string, + Extract + >(); + for (const message of messages) { + for (const part of message.content) { + if (part.type === "toolCall") { + if (!toolCallsById.has(part.id)) { + toolCallOrder.push(part.id); + } + toolCallsById.set(part.id, part); + continue; + } + if (part.type === "thinking") { + // Reasoning is an accumulated snapshot, not a per-message delta. Keep + // only the terminal snapshot when one API call emits phased chunks. + terminalThinking = part; + continue; + } + const previous = narrative.at(-1); + if (part.type === "text" && previous?.type === "text") { + narrative[narrative.length - 1] = { ...previous, text: previous.text + part.text }; + } else { + narrative.push(part); + } + } + } + const toolCalls = toolCallOrder.flatMap((id) => { + const toolCall = toolCallsById.get(id); + return toolCall ? [toolCall] : []; + }); + const content = [...(terminalThinking ? [terminalThinking] : []), ...narrative, ...toolCalls]; + const toolCallIds = [...toolCallOrder]; + return { + message: { + ...last, + content, + stopReason: toolCallIds.length > 0 ? "toolUse" : "stop", + }, + replayIncomplete, + toolCallIds, + }; +} + +function hasUnprojectedAssistantReplayState( + event: Extract, +): boolean { + // The SDK contract marks these as provider/session-bound state or custom + // call shape. AgentMessage cannot represent them, so native replay must stay. + return ( + event.data.citations !== undefined || + event.data.serverTools !== undefined || + event.data.reasoningWireField !== undefined || + event.data.reasoningOpaque !== undefined || + event.data.encryptedContent !== undefined || + event.data.toolRequests?.some((request) => request.type === "custom") === true + ); +} + function isAssistantMessageEvent( event: SessionEvent | undefined, ): event is Extract { diff --git a/src/agents/command/attempt-execution.cli.test.ts b/src/agents/command/attempt-execution.cli.test.ts index dd8e0582ae51..83592f1db9d8 100644 --- a/src/agents/command/attempt-execution.cli.test.ts +++ b/src/agents/command/attempt-execution.cli.test.ts @@ -1779,6 +1779,52 @@ describe("CLI attempt execution", () => { ); }); + it("does not gap-fill an assistant already owned by the runtime", async () => { + const sessionKey = "agent:main:direct:runtime-owned-assistant"; + const sessionEntry: SessionEntry = { + sessionId: "session-runtime-owned-assistant", + updatedAt: Date.now(), + }; + await appendTranscriptMessage( + { agentId: "main", sessionId: sessionEntry.sessionId, sessionKey, storePath }, + { + message: { + role: "assistant", + content: [{ type: "text", text: "runtime answer" }], + timestamp: Date.now(), + }, + cwd: tmpDir, + }, + ); + + await persistCliTurnTranscript({ + body: "ignored prompt", + result: makeCliResult("runtime answer"), + sessionId: sessionEntry.sessionId, + sessionKey, + sessionEntry, + storePath, + sessionAgentId: "main", + sessionCwd: tmpDir, + config: {}, + embeddedAssistantGapFill: true, + skipAssistantTurn: true, + }); + + const messages = await readSessionMessages( + formatSqliteSessionFileMarker({ + agentId: "main", + sessionId: sessionEntry.sessionId, + storePath, + }), + ); + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: "runtime answer" }], + }); + }); + it("persists a media-only ACP user turn when the reply is empty", async () => { const sessionKey = "agent:main:direct:acp-media-only"; const sessionFile = path.join(tmpDir, "session-acp-media-only.jsonl"); diff --git a/src/agents/command/attempt-execution.ts b/src/agents/command/attempt-execution.ts index 1139eb85596f..6c2227704d5f 100644 --- a/src/agents/command/attempt-execution.ts +++ b/src/agents/command/attempt-execution.ts @@ -156,6 +156,7 @@ type PersistTextTurnTranscriptParams = { sessionCwd: string; config: OpenClawConfig; embeddedAssistantGapFill?: boolean; + skipAssistantTurn?: boolean; assistant: { api: string; provider: string; @@ -274,7 +275,7 @@ async function persistTextTurnTranscript( params: PersistTextTurnTranscriptParams, ): Promise { const promptText = params.transcriptBody ?? params.body; - const replyText = params.finalText; + const replyText = params.skipAssistantTurn === true ? "" : params.finalText; const userMessage = params.userMessage ?? (promptText @@ -416,6 +417,7 @@ export async function persistCliTurnTranscript(params: { config: OpenClawConfig; embeddedAssistantGapFill?: boolean; skipUserTurn?: boolean; + skipAssistantTurn?: boolean; }): Promise { const replyText = resolveCliTranscriptReplyText(params.result); const provider = params.result.meta.agentMeta?.provider?.trim() ?? "cli"; @@ -445,6 +447,7 @@ export async function persistCliTurnTranscript(params: { model, usage: params.result.meta.agentMeta?.usage, }, + skipAssistantTurn: params.skipAssistantTurn, }); } diff --git a/src/agents/command/post-run.ts b/src/agents/command/post-run.ts index 50461b9a258d..80cb0289836e 100644 --- a/src/agents/command/post-run.ts +++ b/src/agents/command/post-run.ts @@ -1,3 +1,4 @@ +import { getReplyPayloadMetadata } from "../../auto-reply/reply-payload.js"; import type { CliDeps } from "../../cli/deps.types.js"; import type { RestartRecoveryTerminalDeliveryEvidenceResult } from "../../config/sessions/restart-recovery-types.js"; import type { SessionEntry } from "../../config/sessions/types.js"; @@ -97,6 +98,12 @@ export async function finalizeEmbeddedAgentCommand(params: { try { await fallbackTrajectoryRecorder?.flush(); + const finalVisiblePayload = result.payloads + ?.toReversed() + .find((payload) => !payload.isError && !payload.isReasoning && payload.text?.trim()); + const assistantTranscriptOwned = + finalVisiblePayload !== undefined && + getReplyPayloadMetadata(finalVisiblePayload)?.assistantTranscriptOwned === true; if (params.opts.internalDeliveryMediaUrls !== undefined) { result = { ...result, @@ -189,6 +196,7 @@ export async function finalizeEmbeddedAgentCommand(params: { sessionCwd: effectiveCwd, config: cfg, embeddedAssistantGapFill, + skipAssistantTurn: assistantTranscriptOwned, skipUserTurn: suppressUserTurnPersistence || userTurnTranscriptRecorder.hasPersisted() || diff --git a/src/config/sessions/session-accessor.sqlite-transcript-write.ts b/src/config/sessions/session-accessor.sqlite-transcript-write.ts index ef6f4e41e308..ca018cbe94bd 100644 --- a/src/config/sessions/session-accessor.sqlite-transcript-write.ts +++ b/src/config/sessions/session-accessor.sqlite-transcript-write.ts @@ -328,6 +328,7 @@ export function appendSqliteTranscriptEventSync( export async function appendSqliteExpectedSessionTranscriptTurn( scope: SessionTranscriptWriteScope, options: { + atomicGroup?: boolean; config?: import("../types.openclaw.js").OpenClawConfig; cwd?: string; expectedLifecycleRevision?: string; @@ -376,6 +377,7 @@ export async function appendSqliteExpectedSessionTranscriptTurn( const { shouldAppend: _shouldAppend, ...appendOptions } = append; const appended = appendSqliteTranscriptMessageInTransaction(transactionDb, resolved, { ...appendOptions, + messageAlreadyRedacted: options.atomicGroup === true, ...((append.cwd ?? options.cwd) ? { cwd: append.cwd ?? options.cwd } : {}), ...((append.config ?? options.config) ? { config: append.config ?? options.config } : {}), }); @@ -383,6 +385,14 @@ export async function appendSqliteExpectedSessionTranscriptTurn( appendedMessages.push(appended); } } + if ( + options.atomicGroup && + (appendedMessages.length !== messages.length || + appendedMessages.some((message) => message.appended) !== + appendedMessages.every((message) => message.appended)) + ) { + throw new Error("SQLite transcript batch was not wholly inserted or replayed"); + } const sessionPatch = buildExpectedTranscriptTurnSessionPatch({ appendedMessages, @@ -590,7 +600,7 @@ function assertSqliteTranscriptSnapshotUnchanged( function appendSqliteTranscriptMessageInTransaction( database: OpenClawAgentDatabase, resolved: ResolvedTranscriptScope, - options: TranscriptMessageAppendOptions, + options: TranscriptMessageAppendOptions & { messageAlreadyRedacted?: boolean }, ): TranscriptMessageAppendResult | undefined { const idempotencyKey = readMessageIdempotencyKey(options.message); if (idempotencyKey && options.idempotencyLookup !== "caller-checked") { @@ -618,7 +628,9 @@ function appendSqliteTranscriptMessageInTransaction( const messageId = options.eventId ?? randomUUID(); const now = options.now ?? Date.now(); - const finalMessage = redactTranscriptMessageForStorage(prepared, options); + const finalMessage = options.messageAlreadyRedacted + ? prepared + : redactTranscriptMessageForStorage(prepared, options); ensureTranscriptHeader(database, resolved, options.cwd, now); const parentId = options.parentId === undefined diff --git a/src/config/sessions/session-accessor.transcript-turn.ts b/src/config/sessions/session-accessor.transcript-turn.ts index 28a682d6b27d..6aa763f77695 100644 --- a/src/config/sessions/session-accessor.transcript-turn.ts +++ b/src/config/sessions/session-accessor.transcript-turn.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import { resolveDefaultAgentId } from "../../agents/agent-scope-config.js"; import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { getRuntimeConfig } from "../io.js"; @@ -8,6 +9,7 @@ import { resolveSessionEntryFromStore, resolveSessionEntrySelection, } from "./session-accessor.entry.js"; +import { redactTranscriptMessageForStorage } from "./session-accessor.sqlite-transcript-store.js"; import { appendSqliteExpectedSessionTranscriptTurn } from "./session-accessor.sqlite.js"; import { shouldUseExplicitTranscriptFile } from "./session-accessor.transcript-target.js"; import { appendTranscriptMessage, emitTranscriptUpdate } from "./session-accessor.transcript.js"; @@ -24,6 +26,42 @@ import { formatSqliteSessionFileMarker, parseSqliteSessionFileMarker } from "./s import { runWithOwnedSessionTranscriptWriteLock } from "./transcript-write-context.js"; import type { SessionEntry } from "./types.js"; +/** Appends one prepared ordered group in the existing transcript turn transaction. */ +export async function appendTranscriptMessages( + scope: SessionTranscriptWriteScope, + options: Pick & { + messages: readonly Omit< + SessionTranscriptTurnMessageAppend, + "config" | "cwd" | "parentId" | "prepareMessageAfterIdempotencyCheck" | "shouldAppend" + >[]; + }, +): Promise[]> { + if (options.messages.length === 0) { + return []; + } + const expectedSessionId = scope.sessionId?.trim(); + if (!expectedSessionId) { + throw new Error("Cannot append a transcript batch without an exact session id"); + } + const turn = await persistExpectedSessionTranscriptTurn(scope, { + atomicGroup: true, + config: options.config, + cwd: options.cwd, + expectedSessionId, + messages: options.messages.map((append) => ({ + ...append, + eventId: append.eventId ?? randomUUID(), + message: redactTranscriptMessageForStorage(append.message, options), + now: append.now ?? Date.now(), + })), + updateMode: "none", + }); + if (turn.rejectedReason) { + throw new Error("Transcript session changed before batch append"); + } + return turn.messages as TranscriptMessageAppendResult[]; +} + /** * Persists one logical transcript turn through the SQLite-backed session target. * Transcript row append(s), the synthetic sessionFile marker, and the requested @@ -134,7 +172,10 @@ async function persistExpectedSessionTranscriptTurn( sessionEntry?: SessionEntry; sessionStore?: Record; }, - options: SessionTranscriptTurnPersistOptions & { expectedSessionId: string }, + options: SessionTranscriptTurnPersistOptions & { + atomicGroup?: boolean; + expectedSessionId: string; + }, ): Promise { const sessionKey = scope.sessionKey?.trim(); if (!scope.storePath || !sessionKey) { @@ -190,6 +231,7 @@ async function persistExpectedSessionTranscriptTurn( expectedLifecycleRevision: options.expectedLifecycleRevision, expectedSessionState: options.expectedSessionState, expectedSessionId, + atomicGroup: options.atomicGroup, messages: options.messages, sessionLifecyclePatch: options.sessionLifecyclePatch, sessionFile: target.sessionFile, diff --git a/src/config/sessions/session-accessor.ts b/src/config/sessions/session-accessor.ts index 57e52f6cf8a9..ab1124e5d296 100644 --- a/src/config/sessions/session-accessor.ts +++ b/src/config/sessions/session-accessor.ts @@ -204,7 +204,10 @@ export { withTranscriptWriteLock, withTranscriptWriteTransaction, } from "./session-accessor.transcript.js"; -export { persistSessionTranscriptTurn } from "./session-accessor.transcript-turn.js"; +export { + appendTranscriptMessages, + persistSessionTranscriptTurn, +} from "./session-accessor.transcript-turn.js"; export { isSessionTranscriptProjectionUnavailableError, readRecentSessionTranscriptMessageEvents, diff --git a/src/plugin-sdk/session-transcript-runtime.test.ts b/src/plugin-sdk/session-transcript-runtime.test.ts index 3b5792316bf4..51039c7c7d46 100644 --- a/src/plugin-sdk/session-transcript-runtime.test.ts +++ b/src/plugin-sdk/session-transcript-runtime.test.ts @@ -13,6 +13,8 @@ import * as transcriptEvents from "../sessions/transcript-events.js"; import { appendAssistantMirrorMessageByIdentity, appendSessionTranscriptMessageByIdentity, + appendSessionTranscriptMessageByIdentityStrict, + appendSessionTranscriptMessagesByIdentity, formatSessionTranscriptMemoryHitKey, parseSessionTranscriptMemoryHitKey, publishSessionTranscriptUpdateByIdentity, @@ -81,6 +83,74 @@ describe("session transcript runtime SDK", () => { expect(loadSessionEntry(scope)?.sessionFile).toBeUndefined(); }); + it("atomically appends and idempotently replays an ordered message group", async () => { + const scope = { + agentId: "main", + sessionId: "batch-session", + sessionKey: "agent:main:batch", + storePath, + }; + await upsertSessionEntry(scope, { sessionId: scope.sessionId, updatedAt: 10 }); + const messages = [ + { + eventId: "batch-assistant", + idempotencyLookup: "scan" as const, + message: { role: "assistant", content: "checking", idempotencyKey: "batch:assistant" }, + now: 1_000, + }, + { + eventId: "batch-result", + idempotencyLookup: "scan" as const, + message: { role: "toolResult", content: "done", idempotencyKey: "batch:result" }, + now: 2_000, + }, + ]; + + const appended = await appendSessionTranscriptMessagesByIdentity({ ...scope, messages }); + const replayed = await appendSessionTranscriptMessagesByIdentity({ ...scope, messages }); + + expect(appended.map((result) => result.appended)).toEqual([true, true]); + expect(replayed.map((result) => result.appended)).toEqual([false, false]); + const events = await readSessionTranscriptEvents(scope); + expect(events).toHaveLength(3); + expect(events.slice(1)).toMatchObject([ + { id: "batch-assistant", parentId: null }, + { id: "batch-result", parentId: "batch-assistant" }, + ]); + }); + + it("distinguishes strict singleton results, suppression, and session rebound", async () => { + const scope = { + agentId: "main", + sessionId: "strict-session", + sessionKey: "agent:main:strict", + storePath, + }; + await upsertSessionEntry(scope, { sessionId: scope.sessionId, updatedAt: 10 }); + + await expect( + appendSessionTranscriptMessageByIdentityStrict({ + ...scope, + message: { role: "user", content: "blocked" }, + prepareMessageAfterIdempotencyCheck: () => undefined, + }), + ).resolves.toEqual({ kind: "suppressed" }); + await expect( + appendSessionTranscriptMessageByIdentityStrict({ + ...scope, + message: { role: "user", content: "persisted", idempotencyKey: "strict:user" }, + }), + ).resolves.toMatchObject({ kind: "result", result: { appended: true } }); + + await upsertSessionEntry(scope, { sessionId: "replacement-session", updatedAt: 20 }); + await expect( + appendSessionTranscriptMessageByIdentityStrict({ + ...scope, + message: { role: "assistant", content: "stale" }, + }), + ).resolves.toEqual({ kind: "rejected", reason: "session-rebound" }); + }); + it("pages raw events across appends and resets after replacement", async () => { const scope = { agentId: "main", diff --git a/src/plugin-sdk/session-transcript-runtime.ts b/src/plugin-sdk/session-transcript-runtime.ts index 9aca31a3fbf2..8c1f89ad0f5f 100644 --- a/src/plugin-sdk/session-transcript-runtime.ts +++ b/src/plugin-sdk/session-transcript-runtime.ts @@ -2,10 +2,12 @@ import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { redactTranscriptMessage } from "../agents/transcript-redact.js"; import { appendTranscriptMessage, + appendTranscriptMessages, isSessionTranscriptProjectionUnavailableError, loadSessionEntry, loadTranscriptEvents, publishTranscriptUpdate, + persistSessionTranscriptTurn, readTranscriptRawDelta, readSessionTranscriptVisibleMessageDelta as readVisibleMessageDelta, readLatestTranscriptAssistantText, @@ -128,6 +130,20 @@ export type SessionTranscriptTarget = SessionTranscriptIdentity & { export type SessionTranscriptAppendMessageParams = SessionTranscriptTargetParams & TranscriptMessageAppendOptions; +export type SessionTranscriptAppendMessagesParams = SessionTranscriptTargetParams & { + config?: TranscriptMessageAppendOptions["config"]; + cwd?: string; + messages: readonly Omit< + TranscriptMessageAppendOptions, + "config" | "cwd" | "parentId" | "prepareMessageAfterIdempotencyCheck" | "useRawWhenLinear" + >[]; +}; + +export type SessionTranscriptStrictMessageAppendResult = + | { kind: "result"; result: TranscriptMessageAppendResult } + | { kind: "suppressed" } + | { kind: "rejected"; reason: "session-rebound" }; + export type SessionTranscriptAssistantMirrorAppendParams = SessionTranscriptReadParams & { config?: OpenClawConfig; deliveryMirror?: SessionTranscriptDeliveryMirror; @@ -345,6 +361,57 @@ export async function appendSessionTranscriptMessageByIdentity( return await appendTranscriptMessage(params, params); } +/** Appends one message while preserving distinct suppression and session-rebind outcomes. */ +export async function appendSessionTranscriptMessageByIdentityStrict( + params: SessionTranscriptAppendMessageParams, +): Promise> { + const expectedSessionId = params.sessionId?.trim(); + if (!expectedSessionId) { + throw new Error("Cannot strictly append a transcript message without an exact session id"); + } + const turn = await persistSessionTranscriptTurn(params, { + ...(params.config ? { config: params.config } : {}), + ...(params.cwd ? { cwd: params.cwd } : {}), + expectedSessionId, + messages: [ + { + ...(params.eventId !== undefined ? { eventId: params.eventId } : {}), + ...(params.idempotencyLookup !== undefined + ? { idempotencyLookup: params.idempotencyLookup } + : {}), + message: params.message, + ...(params.now !== undefined ? { now: params.now } : {}), + ...(params.parentId !== undefined ? { parentId: params.parentId } : {}), + ...(params.prepareMessageAfterIdempotencyCheck + ? { + prepareMessageAfterIdempotencyCheck: (message: unknown) => + params.prepareMessageAfterIdempotencyCheck?.(message as TMessage), + } + : {}), + ...(params.useRawWhenLinear !== undefined + ? { useRawWhenLinear: params.useRawWhenLinear } + : {}), + }, + ], + updateMode: "none", + }); + if (turn.rejectedReason) { + return { kind: "rejected", reason: turn.rejectedReason }; + } + const result = turn.messages[0] as TranscriptMessageAppendResult | undefined; + return result ? { kind: "result", result } : { kind: "suppressed" }; +} + +/** + * Atomically appends one ordered, already-hooked message group. Preparation and + * redaction finish before SQLite begins; this is the canonical future harness seam. + */ +export async function appendSessionTranscriptMessagesByIdentity( + params: SessionTranscriptAppendMessagesParams, +): Promise[]> { + return await appendTranscriptMessages(params, params); +} + /** * Publishes a transcript update by scoped transcript target. */ diff --git a/test/scripts/oxlint-config.test.ts b/test/scripts/oxlint-config.test.ts index 962dd9cff84c..cdc929d0709e 100644 --- a/test/scripts/oxlint-config.test.ts +++ b/test/scripts/oxlint-config.test.ts @@ -221,20 +221,36 @@ describe("oxlint config", () => { const maxLinesOverrides = (config.overrides ?? []).filter( (override) => override.rules?.["max-lines"], ); + const scopedBudgets = maxLinesOverrides.filter((override) => override.excludeFiles); + const exactExceptions = maxLinesOverrides.filter((override) => !override.excludeFiles); - expect(maxLinesOverrides).toHaveLength(4); - expect(maxLinesOverrides.map((override) => override.rules?.["max-lines"])).toEqual([ + expect(scopedBudgets).toHaveLength(4); + expect(scopedBudgets.map((override) => override.rules?.["max-lines"])).toEqual([ ["error", { max: 700, skipBlankLines: true, skipComments: true }], ["error", { max: 700, skipBlankLines: true, skipComments: true }], ["error", { max: 800, skipBlankLines: true, skipComments: true }], ["error", { max: 1000, skipBlankLines: true, skipComments: true }], ]); - for (const override of maxLinesOverrides) { + for (const override of scopedBudgets) { expect(override.excludeFiles).toContain("**/protocol-gen/**"); expect(override.excludeFiles).toContain("**/*.generated.*"); expect(override.excludeFiles).toContain("ui/src/i18n/locales/**"); expect(override.excludeFiles).toContain("src/wizard/i18n/locales/**"); } + expect(exactExceptions).toEqual([ + { + files: ["extensions/copilot/src/event-bridge.ts"], + rules: { + "max-lines": ["error", { max: 950, skipBlankLines: true, skipComments: true }], + }, + }, + { + files: ["extensions/copilot/src/attempt-transcript-journal.test.ts"], + rules: { + "max-lines": ["error", { max: 1200, skipBlankLines: true, skipComments: true }], + }, + }, + ]); }); it("enables strict empty object type lint with named single-extends interfaces allowed", () => { diff --git a/test/vitest/vitest.extension-codex-app-server-attempt-extra.config.ts b/test/vitest/vitest.extension-codex-app-server-attempt-extra.config.ts index dd96461c60f6..d827931767ec 100644 --- a/test/vitest/vitest.extension-codex-app-server-attempt-extra.config.ts +++ b/test/vitest/vitest.extension-codex-app-server-attempt-extra.config.ts @@ -6,7 +6,6 @@ function createExtensionCodexAppServerAttemptExtraVitestConfig( ) { return createScopedVitestConfig( [ - "extensions/codex/src/app-server/run-attempt-client-prewarm.test.ts", "extensions/codex/src/app-server/run-attempt-lifecycle-controller.test.ts", "extensions/codex/src/app-server/run-attempt-thread-cleanup.test.ts", "extensions/codex/src/app-server/run-attempt.context-engine.test.ts",