From b093f23ee1f3daf2d4e372102cf18a281e772840 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Mon, 24 Aug 2026 15:22:42 +0530 Subject: [PATCH] fix(ui): keep native tool runs in one history group (#128645) Persist authoritative run ownership on native assistant and tool-result transcript rows so Control UI history can group each run consistently. Co-authored-by: Ayaan Zaidi --- ...ool-result-guard.transcript-events.test.ts | 20 ++++++++++++++++ src/agents/session-tool-result-guard.ts | 24 +++++++++++++------ src/config/sessions/session-accessor.test.ts | 1 + .../session-accessor.transcript-turn.ts | 11 +++++++-- src/config/sessions/session-accessor.types.ts | 2 +- .../chat.directive-tags.test.ts | 3 ++- .../sessions-history-http.revocation.test.ts | 3 ++- .../transcript-commit.test.ts | 13 +++++++++- .../worker-environments/transcript-commit.ts | 12 +++++++--- src/sessions/transcript-events.test.ts | 17 +++++++++++++ src/sessions/transcript-events.ts | 20 ++++++++++++++++ .../e2e/chat-agent-run-transcript.e2e.test.ts | 20 +++++----------- 12 files changed, 116 insertions(+), 30 deletions(-) diff --git a/src/agents/session-tool-result-guard.transcript-events.test.ts b/src/agents/session-tool-result-guard.transcript-events.test.ts index 6acea4a47ede..75bca335a542 100644 --- a/src/agents/session-tool-result-guard.transcript-events.test.ts +++ b/src/agents/session-tool-result-guard.transcript-events.test.ts @@ -1,5 +1,6 @@ // Verifies guarded session managers emit transcript update events with stable sequence ids. import path from "node:path"; +import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; @@ -380,8 +381,27 @@ describe("guardSessionManager transcript updates", () => { timestamp: Date.now(), } as AgentMessage); + expect( + sm + .getEntries() + .filter((entry) => entry.type === "message") + .map((entry) => ({ + role: entry.message.role, + runId: asNullableRecord(asNullableRecord(entry.message)?.["__openclaw"])?.runId, + })), + ).toEqual([ + { role: "user", runId: undefined }, + { role: "assistant", runId: "run-owning-final" }, + { role: "toolResult", runId: "run-owning-final" }, + { role: "assistant", runId: "run-owning-final" }, + ]); expect(getBranchSpy).toHaveBeenCalledTimes(1); expect(updates.map((update) => update.messageSeq)).toEqual([2, 4]); + expect( + updates.map( + (update) => asNullableRecord(asNullableRecord(update.message)?.["__openclaw"])?.runId, + ), + ).toEqual(["run-owning-final", "run-owning-final"]); expect(updates.map((update) => update.runId)).toEqual([undefined, "run-owning-final"]); getBranchSpy.mockRestore(); }); diff --git a/src/agents/session-tool-result-guard.ts b/src/agents/session-tool-result-guard.ts index f577f11f99f5..0e8017c85c63 100644 --- a/src/agents/session-tool-result-guard.ts +++ b/src/agents/session-tool-result-guard.ts @@ -24,7 +24,10 @@ import type { PluginHookBeforeMessageWriteEvent, PluginHookBeforeMessageWriteResult, } from "../plugins/types.js"; -import { resolveTerminalAssistantTranscriptRunId } from "../sessions/transcript-events.js"; +import { + attachSessionTranscriptRunId, + resolveTerminalAssistantTranscriptRunId, +} from "../sessions/transcript-events.js"; import { isTranscriptOnlyOpenClawAssistantModel } from "../shared/transcript-only-openclaw-assistant.js"; import { formatContextLimitTruncationNotice } from "./embedded-agent-runner/context-truncation-notice.js"; import { @@ -678,22 +681,28 @@ export function installSessionToolResultGuard( ): { anchor?: TranscriptEntryAnchor; entryId: string; + message: AgentMessage; messageSeq?: number; sessionTarget?: ReturnType; } => { + const runOwnedMessage = attachSessionTranscriptRunId(message, transcriptRunId); const parentEntryId = sessionManager.getLeafId(); const appendParentEntryId = sessionManager.getAppendParentId(); - const { entryId, anchor } = originalAppendWithTranscriptAnchor(message as never, options); + const { entryId, anchor } = originalAppendWithTranscriptAnchor( + runOwnedMessage as never, + options, + ); if (sessionManager.getAppendParentId() === appendParentEntryId) { - return { entryId, ...(anchor ? { anchor } : {}) }; + return { entryId, message: runOwnedMessage, ...(anchor ? { anchor } : {}) }; } - void opts?.onMessagePersisted?.(message); + void opts?.onMessagePersisted?.(runOwnedMessage); const sessionTarget = sessionManager.getSessionTarget(); if (!sessionTarget) { - return { entryId, ...(anchor ? { anchor } : {}) }; + return { entryId, message: runOwnedMessage, ...(anchor ? { anchor } : {}) }; } return { entryId, + message: runOwnedMessage, ...(anchor ? { anchor } : {}), sessionTarget, messageSeq: resolveAppendedMessageSeq({ @@ -907,6 +916,7 @@ export function installSessionToolResultGuard( const { anchor, entryId: result, + message: persistedMessage, messageSeq, sessionTarget, } = appendMessageAndCacheTranscriptSeq(finalMessage, { @@ -914,9 +924,9 @@ export function installSessionToolResultGuard( callerInvalidatesCache || transformedMessage !== nextMessage || finalWrite.changed, }); if (sessionTarget) { - const runId = resolveTerminalAssistantTranscriptRunId(finalMessage, transcriptRunId); + const runId = resolveTerminalAssistantTranscriptRunId(persistedMessage, transcriptRunId); void publishTranscriptUpdate(sessionTarget, { - message: finalMessage, + message: persistedMessage, messageId: typeof result === "string" ? result : undefined, ...(messageSeq !== undefined ? { messageSeq } : {}), ...(runId ? { runId } : {}), diff --git a/src/config/sessions/session-accessor.test.ts b/src/config/sessions/session-accessor.test.ts index a533e724b509..04983f450200 100644 --- a/src/config/sessions/session-accessor.test.ts +++ b/src/config/sessions/session-accessor.test.ts @@ -3357,6 +3357,7 @@ describe("session accessor seam", () => { content: "second committed message", idempotencyKey: "ordered-turn-second", timestamp: 3, + __openclaw: { runId: "run-ordered-turn" }, }, messageId: result.messages[1]?.messageId, messageSeq: 3, diff --git a/src/config/sessions/session-accessor.transcript-turn.ts b/src/config/sessions/session-accessor.transcript-turn.ts index e3e651f63d9d..18423f630854 100644 --- a/src/config/sessions/session-accessor.transcript-turn.ts +++ b/src/config/sessions/session-accessor.transcript-turn.ts @@ -5,7 +5,10 @@ import { normalizeAgentId, parseAgentSessionKey, } from "../../routing/session-key.js"; -import { resolveTerminalAssistantTranscriptRunId } from "../../sessions/transcript-events.js"; +import { + attachSessionTranscriptRunId, + resolveTerminalAssistantTranscriptRunId, +} from "../../sessions/transcript-events.js"; import { getRuntimeConfig } from "../io.js"; import { tryResolveLegacyCompatibilityAgentId } from "../legacy.default-agent-owner.js"; import type { OpenClawConfig } from "../types.openclaw.js"; @@ -232,6 +235,7 @@ async function appendTranscriptTurnMessages( }, { ...appendOptions, + message: attachSessionTranscriptRunId(appendOptions.message, options.runId), ...((append.cwd ?? options.cwd) ? { cwd: append.cwd ?? options.cwd } : {}), ...((append.config ?? options.config) ? { config: append.config ?? options.config } : {}), }, @@ -348,7 +352,10 @@ async function persistExpectedSessionTranscriptTurn( expectedSessionState: options.expectedSessionState, expectedSessionId, atomicGroup: options.atomicGroup, - messages: options.messages, + messages: options.messages.map((append) => ({ + ...append, + message: attachSessionTranscriptRunId(append.message, options.runId), + })), sessionLifecyclePatch: options.sessionLifecyclePatch, sessionFile: target.sessionKey!, touchSessionEntry: options.touchSessionEntry, diff --git a/src/config/sessions/session-accessor.types.ts b/src/config/sessions/session-accessor.types.ts index b7a3d81ddf3f..f34c985b3b01 100644 --- a/src/config/sessions/session-accessor.types.ts +++ b/src/config/sessions/session-accessor.types.ts @@ -403,7 +403,7 @@ export type SessionTranscriptTurnPersistOptions = { sessionLifecyclePatch?: SessionTranscriptTurnLifecyclePatch; /** Message rows to append under one transcript write lock. */ messages: readonly SessionTranscriptTurnMessageAppend[]; - /** Exact run provenance emitted only for terminal assistant message updates. */ + /** Exact run provenance persisted on output rows and emitted on terminal assistant updates. */ runId?: string; /** Publish each appended message inline, one file-only invalidation, or nothing. */ updateMode?: SessionTranscriptTurnUpdateMode; diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index 428cbf0a711f..2a0f7d740e8c 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -555,9 +555,10 @@ vi.mock("../../plugins/hook-runner-global.js", () => { }); vi.mock("../../sessions/transcript-events.js", async (importOriginal) => { - const { resolveTerminalAssistantTranscriptRunId } = + const { attachSessionTranscriptRunId, resolveTerminalAssistantTranscriptRunId } = await importOriginal(); return { + attachSessionTranscriptRunId, resolveTerminalAssistantTranscriptRunId, emitSessionTranscriptUpdate: vi.fn( (update: { diff --git a/src/gateway/sessions-history-http.revocation.test.ts b/src/gateway/sessions-history-http.revocation.test.ts index bb847e5f1bf4..47fcad92d39d 100644 --- a/src/gateway/sessions-history-http.revocation.test.ts +++ b/src/gateway/sessions-history-http.revocation.test.ts @@ -30,9 +30,10 @@ vi.mock("../config/config.js", () => ({ })); vi.mock("../sessions/transcript-events.js", async (importOriginal) => { - const { resolveTerminalAssistantTranscriptRunId } = + const { attachSessionTranscriptRunId, resolveTerminalAssistantTranscriptRunId } = await importOriginal(); return { + attachSessionTranscriptRunId, resolveTerminalAssistantTranscriptRunId, onInternalSessionTranscriptUpdate: (cb: typeof transcriptUpdateHandler) => { transcriptUpdateHandler = cb; diff --git a/src/gateway/worker-environments/transcript-commit.test.ts b/src/gateway/worker-environments/transcript-commit.test.ts index be3244256790..3c46b67106e8 100644 --- a/src/gateway/worker-environments/transcript-commit.test.ts +++ b/src/gateway/worker-environments/transcript-commit.test.ts @@ -722,7 +722,7 @@ describe("worker transcript commit application", () => { }); }); - it("advances sequential commits and assigns run ownership only to the terminal assistant", async () => { + it("persists run ownership on worker output while only the terminal envelope completes it", async () => { const updates: Parameters[0]>[0][] = []; unsubscribe = onSessionTranscriptUpdate((update) => updates.push(update)); const first = await committer.commit({ identity: IDENTITY, request: createRequest() }); @@ -757,6 +757,17 @@ describe("worker transcript commit application", () => { expect(second.result.newLeafId).toBe(second.result.entryIds[0]); expect(second.result.newLeafId).not.toBe(first.result.newLeafId); const reopened = SessionManager.open(sessionTarget); + expect( + reopened + .getEntries() + .filter((entry) => entry.type === "message") + .map((entry) => entry.message), + ).toMatchObject([ + { role: "user" }, + { role: "assistant", __openclaw: { runId: IDENTITY.runId } }, + { role: "toolResult", __openclaw: { runId: IDENTITY.runId } }, + { role: "assistant", __openclaw: { runId: IDENTITY.runId } }, + ]); expect(reopened.getEntries().at(-1)).toMatchObject({ id: second.result.newLeafId, parentId: first.result.newLeafId, diff --git a/src/gateway/worker-environments/transcript-commit.ts b/src/gateway/worker-environments/transcript-commit.ts index c6132a2d1720..5257129f3fe0 100644 --- a/src/gateway/worker-environments/transcript-commit.ts +++ b/src/gateway/worker-environments/transcript-commit.ts @@ -15,7 +15,10 @@ import { } from "../../config/sessions/session-accessor.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js"; -import { resolveTerminalAssistantTranscriptRunId } from "../../sessions/transcript-events.js"; +import { + attachSessionTranscriptRunId, + resolveTerminalAssistantTranscriptRunId, +} from "../../sessions/transcript-events.js"; import type { WorkerConnectionIdentity } from "./connection-identity.js"; import { resolveWorkerSessionTarget, type ResolvedWorkerSessionTarget } from "./session-target.js"; import { @@ -321,8 +324,11 @@ async function applyWorkerTranscriptCommit(params: { sessionId: string; target: ResolvedWorkerSessionTarget; }): Promise { - const redactedMessages = params.messages.map( - (message) => redactTranscriptMessage(message, params.config) as CommittedAgentMessage, + const redactedMessages = params.messages.map((message) => + attachSessionTranscriptRunId( + redactTranscriptMessage(message, params.config) as CommittedAgentMessage, + params.runId, + ), ); const expectedState = { sessionId: params.sessionId, diff --git a/src/sessions/transcript-events.test.ts b/src/sessions/transcript-events.test.ts index 29f317b945ab..feb69a82121e 100644 --- a/src/sessions/transcript-events.test.ts +++ b/src/sessions/transcript-events.test.ts @@ -1,6 +1,7 @@ // Transcript event tests cover transcript event parsing and compaction. import { afterEach, describe, expect, it, vi } from "vitest"; import { + attachSessionTranscriptRunId, emitSessionTranscriptUpdate, onInternalSessionTranscriptUpdate, onSessionTranscriptUpdate, @@ -16,6 +17,22 @@ afterEach(() => { }); describe("transcript events", () => { + it.each(["assistant", "toolResult"])("persists normalized run ownership on %s rows", (role) => { + const message = { role, content: [], __openclaw: { seq: 2 } }; + + expect(attachSessionTranscriptRunId(message, " run-owned ")).toEqual({ + ...message, + __openclaw: { seq: 2, runId: "run-owned" }, + }); + expect(attachSessionTranscriptRunId(message, " ")).toBe(message); + }); + + it("does not assign output run ownership to user rows", () => { + const message = { role: "user", content: "prompt" }; + + expect(attachSessionTranscriptRunId(message, "run-owned")).toBe(message); + }); + it("emits trimmed archive file updates only to internal listeners", () => { const listener = vi.fn(); cleanup.push(onInternalSessionTranscriptUpdate(listener)); diff --git a/src/sessions/transcript-events.ts b/src/sessions/transcript-events.ts index 085021477425..62b25031ca9b 100644 --- a/src/sessions/transcript-events.ts +++ b/src/sessions/transcript-events.ts @@ -38,6 +38,26 @@ export type SessionTranscriptUpdate = Omit< /** Internal transcript update that may identify a transcript without a file path. */ export type InternalSessionTranscriptUpdate = SessionTranscriptUpdateFields; +/** Persists authoritative run ownership on assistant and tool-result rows. */ +export function attachSessionTranscriptRunId(message: T, runId: string | null | undefined): T { + const normalizedRunId = normalizeOptionalString(runId); + if ( + !normalizedRunId || + !isRecord(message) || + (message.role !== "assistant" && message.role !== "toolResult") + ) { + return message; + } + const metadata = isRecord(message["__openclaw"]) ? message["__openclaw"] : {}; + if (metadata.runId === normalizedRunId) { + return message; + } + return { + ...message, + __openclaw: { ...metadata, runId: normalizedRunId }, + }; +} + /** Correlates only terminal assistant rows with the run that actually produced them. */ export function resolveTerminalAssistantTranscriptRunId( message: unknown, diff --git a/ui/src/e2e/chat-agent-run-transcript.e2e.test.ts b/ui/src/e2e/chat-agent-run-transcript.e2e.test.ts index 58259432ed3d..9e60bf90a458 100644 --- a/ui/src/e2e/chat-agent-run-transcript.e2e.test.ts +++ b/ui/src/e2e/chat-agent-run-transcript.e2e.test.ts @@ -20,7 +20,7 @@ function transcriptMessage( role, content, timestamp: Date.UTC(2026, 7, 19, 12, 0, seq), - __openclaw: { id, idempotencyKey: runId, seq }, + __openclaw: role === "user" ? { id, idempotencyKey: runId, seq } : { id, runId, seq }, }; } @@ -68,7 +68,6 @@ suite.define(() => { ), toolCallId: "call-read", toolName: "read", - runId: firstRunId, }, transcriptMessage( "assistant", @@ -103,7 +102,6 @@ suite.define(() => { ), toolCallId: "call-render", toolName: "exec", - runId: firstRunId, }, transcriptMessage( "assistant", @@ -131,7 +129,6 @@ suite.define(() => { ), toolCallId: "call-tool-only", toolName: "read", - runId: toolOnlyRunId, }, transcriptMessage( "user", @@ -157,7 +154,6 @@ suite.define(() => { ), toolCallId: "call-commentary-tool-only", toolName: "read", - runId: commentaryToolRunId, }, ], }); @@ -166,21 +162,17 @@ suite.define(() => { const transcript = page.locator(".chat-thread-inner"); await transcript.getByText("Caption ready for the second run.", { exact: true }).waitFor(); - const artifactDir = process.env.OPENCLAW_CONTROL_UI_E2E_ARTIFACT_DIR?.trim(); - if (artifactDir) { - await fs.mkdir(artifactDir, { recursive: true }); - await page.screenshot({ - path: path.join(artifactDir, "agent-run-transcript.png"), - fullPage: true, - }); - } - const assistantGroups = page.locator(".chat-group.assistant"); expect(await assistantGroups.count()).toBe(4); const firstRun = assistantGroups.filter({ hasText: "I’ll create the launch card and check the existing style first.", }); expect(await firstRun.count()).toBe(1); + const artifactDir = process.env.OPENCLAW_CONTROL_UI_E2E_ARTIFACT_DIR?.trim(); + if (artifactDir) { + await fs.mkdir(artifactDir, { recursive: true }); + await firstRun.screenshot({ path: path.join(artifactDir, "agent-run-transcript.png") }); + } expect(await firstRun.locator(".chat-sender-name").count()).toBe(1); expect(await firstRun.locator(".chat-group-footer-actions").count()).toBe(1); expect(await firstRun.locator(".chat-message-actions-row").count()).toBe(0);