Skip to content

Commit 63ce0ca

Browse files
neeravmakwanacursoragentobviyus
authored
fix: persist embedded session transcripts (#77839) (thanks @neeravmakwana)
* fix(agents): persist embedded runner session transcripts (#77823) Run persistCliTurnTranscript and post-turn compaction for executionTrace.runner embedded, matching CLI turns so assistant text reaches session JSONL for webchat/Feishu-style runs. Co-authored-by: Cursor <[email protected]> * fix(agents): narrow embedded transcript mirror with assistant dedupe (#77823) Embedded runs pass embeddedAssistantGapFill so persistCliTurnTranscript skips re-appending the user prompt Pi owns and only appends assistant text when the transcript tail lacks equivalent visible assistant content. Adds CLI transcript regression coverage for gap-fill dedupe. Co-authored-by: Cursor <[email protected]> * fix(agents): dedupe embedded transcript gap fill by tail * fix: persist embedded session transcripts (#77839) (thanks @neeravmakwana) --------- Co-authored-by: Cursor <[email protected]> Co-authored-by: Ayaan Zaidi <[email protected]>
1 parent 3a0812b commit 63ce0ca

6 files changed

Lines changed: 253 additions & 43 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,7 @@ Docs: https://docs.openclaw.ai
330330
- Agents/embed: keep message_end safety delivery armed when a silent text_end chunk produces no block reply, fixing dropped Telegram/forum replies. Fixes #77833. (#77840) Thanks @neeravmakwana.
331331
- Install/postinstall: skip noisy compile-cache prune warnings when `EACCES`/`EPERM` prevent removing shared `/tmp/node-compile-cache` entries owned by another user. Fixes #76353. (#76362) Thanks @RayWoo and @neeravmakwana.
332332
- Agents/messaging: surface CLI subprocess watchdog/turn timeout messages to chat users when verbose failures are off, instead of collapsing them into generic external-run failure copy. Fixes #77007. (#77015) Thanks @neeravmakwana.
333+
- Agents/sessions: after embedded Pi runs, append assistant-visible reply text to session JSONL only when Pi did not already persist an equivalent tail assistant entry, without re-mirroring the user prompt Pi owns. Fixes #77823. (#77839) Thanks @neeravmakwana.
333334

334335
## 2026.5.3-1
335336

src/agents/agent-command.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1196,7 +1196,8 @@ async function agentCommandInternal(
11961196
sessionEntry = sessionStore[sessionKey] ?? sessionEntry;
11971197
}
11981198

1199-
if (result.meta.executionTrace?.runner === "cli") {
1199+
const transcriptPersistenceRunner = result.meta.executionTrace?.runner;
1200+
if (transcriptPersistenceRunner === "cli" || transcriptPersistenceRunner === "embedded") {
12001201
try {
12011202
sessionEntry = await attemptExecutionRuntime.persistCliTurnTranscript({
12021203
body,
@@ -1211,6 +1212,7 @@ async function agentCommandInternal(
12111212
threadId: opts.threadId,
12121213
sessionCwd: workspaceDir,
12131214
config: cfg,
1215+
embeddedAssistantGapFill: transcriptPersistenceRunner === "embedded",
12141216
});
12151217
sessionEntry = await (
12161218
await loadCliCompactionRuntime()
@@ -1235,7 +1237,7 @@ async function agentCommandInternal(
12351237
});
12361238
} catch (error) {
12371239
log.warn(
1238-
`CLI transcript persistence failed for ${sessionKey ?? sessionId}: ${error instanceof Error ? error.message : String(error)}`,
1240+
`Turn transcript persistence failed for ${sessionKey ?? sessionId}: ${error instanceof Error ? error.message : String(error)}`,
12391241
);
12401242
}
12411243
}

src/agents/command/attempt-execution.cli.test.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import os from "node:os";
33
import path from "node:path";
44
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
55
import type { SessionEntry } from "../../config/sessions.js";
6+
import { appendSessionTranscriptMessage } from "../../config/sessions/transcript-append.js";
67
import type { OpenClawConfig } from "../../config/types.openclaw.js";
78
import { FailoverError } from "../failover-error.js";
89
import { runEmbeddedPiAgent, type EmbeddedPiRunResult } from "../pi-embedded.js";
@@ -418,6 +419,130 @@ describe("CLI attempt execution", () => {
418419
});
419420
});
420421

422+
it("embedded assistant gap-fill skips user mirror and dedupes identical assistant tails", async () => {
423+
const sessionKey = "agent:main:subagent:embedded-gap-fill";
424+
const sessionEntry: SessionEntry = {
425+
sessionId: "session-embedded-gap-fill",
426+
updatedAt: Date.now(),
427+
};
428+
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
429+
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
430+
431+
const result = makeCliResult("already mirrored");
432+
result.meta.executionTrace = {
433+
winnerProvider: "anthropic",
434+
winnerModel: "claude-opus-4-6",
435+
fallbackUsed: false,
436+
runner: "embedded",
437+
};
438+
439+
const updatedFirst = await persistCliTurnTranscript({
440+
body: "ignored for gap fill",
441+
transcriptBody: "also ignored",
442+
result,
443+
sessionId: sessionEntry.sessionId,
444+
sessionKey,
445+
sessionEntry,
446+
sessionStore,
447+
storePath,
448+
sessionAgentId: "main",
449+
sessionCwd: tmpDir,
450+
config: {},
451+
embeddedAssistantGapFill: true,
452+
});
453+
454+
let messages = await readSessionMessages(updatedFirst?.sessionFile ?? "");
455+
expect(messages).toHaveLength(1);
456+
expect(messages[0]).toMatchObject({
457+
role: "assistant",
458+
content: [{ type: "text", text: "already mirrored" }],
459+
});
460+
461+
await persistCliTurnTranscript({
462+
body: "still ignored",
463+
result,
464+
sessionId: sessionEntry.sessionId,
465+
sessionKey,
466+
sessionEntry: updatedFirst,
467+
sessionStore,
468+
storePath,
469+
sessionAgentId: "main",
470+
sessionCwd: tmpDir,
471+
config: {},
472+
embeddedAssistantGapFill: true,
473+
});
474+
475+
messages = await readSessionMessages(updatedFirst?.sessionFile ?? "");
476+
expect(messages).toHaveLength(1);
477+
});
478+
479+
it("embedded assistant gap-fill appends repeated replies after a user tail", async () => {
480+
const sessionKey = "agent:main:subagent:embedded-repeated-reply";
481+
const sessionEntry: SessionEntry = {
482+
sessionId: "session-embedded-repeated-reply",
483+
updatedAt: Date.now(),
484+
};
485+
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
486+
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
487+
488+
const result = makeCliResult("same answer");
489+
result.meta.executionTrace = {
490+
winnerProvider: "anthropic",
491+
winnerModel: "claude-opus-4-6",
492+
fallbackUsed: false,
493+
runner: "embedded",
494+
};
495+
496+
const updatedFirst = await persistCliTurnTranscript({
497+
body: "ignored for gap fill",
498+
result,
499+
sessionId: sessionEntry.sessionId,
500+
sessionKey,
501+
sessionEntry,
502+
sessionStore,
503+
storePath,
504+
sessionAgentId: "main",
505+
sessionCwd: tmpDir,
506+
config: {},
507+
embeddedAssistantGapFill: true,
508+
});
509+
const sessionFile = updatedFirst?.sessionFile;
510+
expect(sessionFile).toBeTruthy();
511+
512+
await appendSessionTranscriptMessage({
513+
transcriptPath: sessionFile!,
514+
sessionId: sessionEntry.sessionId,
515+
cwd: tmpDir,
516+
config: {},
517+
message: {
518+
role: "user",
519+
content: "next prompt",
520+
timestamp: Date.now(),
521+
},
522+
});
523+
524+
await persistCliTurnTranscript({
525+
body: "still ignored",
526+
result,
527+
sessionId: sessionEntry.sessionId,
528+
sessionKey,
529+
sessionEntry: updatedFirst,
530+
sessionStore,
531+
storePath,
532+
sessionAgentId: "main",
533+
sessionCwd: tmpDir,
534+
config: {},
535+
embeddedAssistantGapFill: true,
536+
});
537+
538+
const messages = await readSessionMessages(sessionFile!);
539+
expect(messages).toHaveLength(3);
540+
expect(messages.map((message) => message.role)).toEqual(["assistant", "user", "assistant"]);
541+
expect(messages[2]).toMatchObject({
542+
content: [{ type: "text", text: "same answer" }],
543+
});
544+
});
545+
421546
it("persists the transcript body instead of runtime-only CLI prompt context", async () => {
422547
const sessionKey = "agent:main:subagent:cli-transcript-clean";
423548
const sessionEntry: SessionEntry = {

src/agents/command/attempt-execution.ts

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core";
22
import { normalizeReplyPayload } from "../../auto-reply/reply/normalize-reply.js";
33
import type { ThinkLevel, VerboseLevel } from "../../auto-reply/thinking.js";
44
import { appendSessionTranscriptMessage } from "../../config/sessions/transcript-append.js";
5-
import { resolveSessionTranscriptFile } from "../../config/sessions/transcript.js";
5+
import {
6+
readTailAssistantTextFromSessionTranscript,
7+
resolveSessionTranscriptFile,
8+
} from "../../config/sessions/transcript.js";
69
import type { SessionEntry } from "../../config/sessions/types.js";
710
import type { OpenClawConfig } from "../../config/types.openclaw.js";
811
import { emitAgentEvent } from "../../infra/agent-events.js";
@@ -45,6 +48,10 @@ export {
4548

4649
const log = createSubsystemLogger("agents/agent-command");
4750

51+
function normalizeTranscriptMirrorText(value: string): string {
52+
return value.trim().replace(/\s+/gu, " ");
53+
}
54+
4855
const ACP_TRANSCRIPT_USAGE = {
4956
input: 0,
5057
output: 0,
@@ -81,6 +88,7 @@ type PersistTextTurnTranscriptParams = {
8188
threadId?: string | number;
8289
sessionCwd: string;
8390
config: OpenClawConfig;
91+
embeddedAssistantGapFill?: boolean;
8492
assistant: {
8593
api: string;
8694
provider: string;
@@ -217,22 +225,33 @@ async function persistTextTurnTranscript(
217225
}
218226

219227
if (replyText) {
220-
await appendSessionTranscriptMessage({
221-
transcriptPath: sessionFile,
222-
sessionId: params.sessionId,
223-
cwd: params.sessionCwd,
224-
config: params.config,
225-
message: {
226-
role: "assistant",
227-
content: [{ type: "text", text: replyText }],
228-
api: params.assistant.api,
229-
provider: params.assistant.provider,
230-
model: params.assistant.model,
231-
usage: resolveTranscriptUsage(params.assistant.usage),
232-
stopReason: "stop",
233-
timestamp: Date.now(),
234-
},
235-
});
228+
let appendAssistant = true;
229+
if (params.embeddedAssistantGapFill) {
230+
const latest = await readTailAssistantTextFromSessionTranscript(sessionFile);
231+
const normalizedReply = normalizeTranscriptMirrorText(replyText);
232+
const normalizedLatest = latest?.text ? normalizeTranscriptMirrorText(latest.text) : "";
233+
if (normalizedLatest && normalizedLatest === normalizedReply) {
234+
appendAssistant = false;
235+
}
236+
}
237+
if (appendAssistant) {
238+
await appendSessionTranscriptMessage({
239+
transcriptPath: sessionFile,
240+
sessionId: params.sessionId,
241+
cwd: params.sessionCwd,
242+
config: params.config,
243+
message: {
244+
role: "assistant",
245+
content: [{ type: "text", text: replyText }],
246+
api: params.assistant.api,
247+
provider: params.assistant.provider,
248+
model: params.assistant.model,
249+
usage: resolveTranscriptUsage(params.assistant.usage),
250+
stopReason: "stop",
251+
timestamp: Date.now(),
252+
},
253+
});
254+
}
236255
}
237256
} finally {
238257
await lock.release();
@@ -296,14 +315,16 @@ export async function persistCliTurnTranscript(params: {
296315
threadId?: string | number;
297316
sessionCwd: string;
298317
config: OpenClawConfig;
318+
embeddedAssistantGapFill?: boolean;
299319
}): Promise<SessionEntry | undefined> {
300320
const replyText = resolveCliTranscriptReplyText(params.result);
301321
const provider = params.result.meta.agentMeta?.provider?.trim() ?? "cli";
302322
const model = params.result.meta.agentMeta?.model?.trim() ?? "default";
323+
const gapFill = params.embeddedAssistantGapFill ?? false;
303324

304325
return await persistTextTurnTranscript({
305-
body: params.body,
306-
transcriptBody: params.transcriptBody,
326+
body: gapFill ? "" : params.body,
327+
transcriptBody: gapFill ? undefined : params.transcriptBody,
307328
finalText: replyText,
308329
sessionId: params.sessionId,
309330
sessionKey: params.sessionKey,
@@ -314,6 +335,7 @@ export async function persistCliTurnTranscript(params: {
314335
threadId: params.threadId,
315336
sessionCwd: params.sessionCwd,
316337
config: params.config,
338+
embeddedAssistantGapFill: gapFill,
317339
assistant: {
318340
api: "cli",
319341
provider,

src/commands/agent.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"
55
import "./agent-command.test-mocks.js";
66
import { __testing as acpManagerTesting } from "../acp/control-plane/manager.js";
77
import * as authProfileStoreModule from "../agents/auth-profiles/store.js";
8+
import * as attemptExecutionRuntime from "../agents/command/attempt-execution.runtime.js";
89
import { loadManifestModelCatalog, loadModelCatalog } from "../agents/model-catalog.js";
910
import * as modelSelectionModule from "../agents/model-selection.js";
1011
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
@@ -392,6 +393,30 @@ describe("agentCommand", () => {
392393
});
393394
});
394395

396+
it("persists embedded-runner turns to the session transcript", async () => {
397+
await withTempHome(async (home) => {
398+
const store = path.join(home, "sessions.json");
399+
mockConfig(home, store);
400+
const base = createDefaultAgentResult({ payloads: [{ text: "assistant-visible" }] });
401+
vi.mocked(runEmbeddedPiAgent).mockResolvedValueOnce({
402+
...base,
403+
meta: {
404+
...base.meta,
405+
executionTrace: { runner: "embedded" },
406+
},
407+
});
408+
409+
await agentCommand({ message: "hello from user", agentId: "main" }, runtime);
410+
411+
expect(vi.mocked(attemptExecutionRuntime.persistCliTurnTranscript)).toHaveBeenCalledTimes(1);
412+
const persistArgs = vi.mocked(attemptExecutionRuntime.persistCliTurnTranscript).mock
413+
.calls[0]?.[0];
414+
expect(persistArgs?.embeddedAssistantGapFill).toBe(true);
415+
expect(persistArgs?.body).toBe("hello from user");
416+
expect(persistArgs?.result.meta?.executionTrace?.runner).toBe("embedded");
417+
});
418+
});
419+
395420
it("passes configured fast mode to embedded runs", async () => {
396421
await withTempHome(async (home) => {
397422
const store = path.join(home, "sessions.json");

0 commit comments

Comments
 (0)