fix(agents): persist cli transcript turns

This commit is contained in:
Ayaan Zaidi
2026-04-16 09:20:37 +05:30
parent c56b56e514
commit b8ef507cc0
4 changed files with 202 additions and 7 deletions

View File

@@ -1044,6 +1044,27 @@ async function agentCommandInternal(
});
}
if (result.meta.executionTrace?.runner === "cli") {
try {
sessionEntry = await attemptExecutionRuntime.persistCliTurnTranscript({
body,
result,
sessionId,
sessionKey: sessionKey ?? sessionId,
sessionEntry,
sessionStore,
storePath,
sessionAgentId,
threadId: opts.threadId,
sessionCwd: workspaceDir,
});
} catch (error) {
log.warn(
`CLI transcript persistence failed for ${sessionKey ?? sessionId}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
const payloads = result.payloads ?? [];
const { deliverAgentCommandResult } = await loadDeliveryRuntime();
return await deliverAgentCommandResult({

View File

@@ -6,6 +6,7 @@ export {
emitAcpLifecycleError,
emitAcpLifecycleStart,
persistAcpTurnTranscript,
persistCliTurnTranscript,
runAgentAttempt,
sessionFileHasContent,
} from "./attempt-execution.js";

View File

@@ -16,8 +16,9 @@ import { clearCliSession, getCliSessionBinding, setCliSessionBinding } from "../
import { FailoverError } from "../failover-error.js";
import { isCliProvider } from "../model-selection.js";
import { prepareSessionManagerForRun } from "../pi-embedded-runner/session-manager-init.js";
import { runEmbeddedPiAgent } from "../pi-embedded.js";
import { runEmbeddedPiAgent, type EmbeddedPiRunResult } from "../pi-embedded.js";
import { buildWorkspaceSkillSnapshot } from "../skills.js";
import { buildUsageWithNoCost } from "../stream-message-shared.js";
import { resolveFallbackRetryPrompt } from "./attempt-execution.helpers.js";
import { persistSessionEntry } from "./attempt-execution.shared.js";
import { resolveAgentRunContext } from "./run-context.js";
@@ -46,7 +47,15 @@ const ACP_TRANSCRIPT_USAGE = {
},
} as const;
export async function persistAcpTurnTranscript(params: {
type TranscriptUsage = {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
total?: number;
};
type PersistTextTurnTranscriptParams = {
body: string;
finalText: string;
sessionId: string;
@@ -57,7 +66,30 @@ export async function persistAcpTurnTranscript(params: {
sessionAgentId: string;
threadId?: string | number;
sessionCwd: string;
}): Promise<SessionEntry | undefined> {
assistant: {
api: string;
provider: string;
model: string;
usage?: TranscriptUsage;
};
};
function resolveTranscriptUsage(usage: PersistTextTurnTranscriptParams["assistant"]["usage"]) {
if (!usage) {
return ACP_TRANSCRIPT_USAGE;
}
return buildUsageWithNoCost({
input: usage.input,
output: usage.output,
cacheRead: usage.cacheRead,
cacheWrite: usage.cacheWrite,
totalTokens: usage.total,
});
}
async function persistTextTurnTranscript(
params: PersistTextTurnTranscriptParams,
): Promise<SessionEntry | undefined> {
const promptText = params.body;
const replyText = params.finalText;
if (!promptText && !replyText) {
@@ -98,10 +130,10 @@ export async function persistAcpTurnTranscript(params: {
sessionManager.appendMessage({
role: "assistant",
content: [{ type: "text", text: replyText }],
api: "openai-responses",
provider: "openclaw",
model: "acp-runtime",
usage: ACP_TRANSCRIPT_USAGE,
api: params.assistant.api,
provider: params.assistant.provider,
model: params.assistant.model,
usage: resolveTranscriptUsage(params.assistant.usage),
stopReason: "stop",
timestamp: Date.now(),
});
@@ -111,6 +143,77 @@ export async function persistAcpTurnTranscript(params: {
return sessionEntry;
}
function resolveCliTranscriptReplyText(result: EmbeddedPiRunResult): string {
const visibleText = result.meta.finalAssistantVisibleText?.trim();
if (visibleText) {
return visibleText;
}
return (result.payloads ?? [])
.filter((payload) => !payload.isError && !payload.isReasoning)
.map((payload) => payload.text?.trim() ?? "")
.filter(Boolean)
.join("\n\n");
}
export async function persistAcpTurnTranscript(params: {
body: string;
finalText: string;
sessionId: string;
sessionKey: string;
sessionEntry: SessionEntry | undefined;
sessionStore?: Record<string, SessionEntry>;
storePath?: string;
sessionAgentId: string;
threadId?: string | number;
sessionCwd: string;
}): Promise<SessionEntry | undefined> {
return await persistTextTurnTranscript({
...params,
assistant: {
api: "openai-responses",
provider: "openclaw",
model: "acp-runtime",
},
});
}
export async function persistCliTurnTranscript(params: {
body: string;
result: EmbeddedPiRunResult;
sessionId: string;
sessionKey: string;
sessionEntry: SessionEntry | undefined;
sessionStore?: Record<string, SessionEntry>;
storePath?: string;
sessionAgentId: string;
threadId?: string | number;
sessionCwd: string;
}): Promise<SessionEntry | undefined> {
const replyText = resolveCliTranscriptReplyText(params.result);
const provider = params.result.meta.agentMeta?.provider?.trim() ?? "cli";
const model = params.result.meta.agentMeta?.model?.trim() ?? "default";
return await persistTextTurnTranscript({
body: params.body,
finalText: replyText,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
sessionEntry: params.sessionEntry,
sessionStore: params.sessionStore,
storePath: params.storePath,
sessionAgentId: params.sessionAgentId,
threadId: params.threadId,
sessionCwd: params.sessionCwd,
assistant: {
api: provider,
provider,
model,
usage: params.result.meta.agentMeta?.usage,
},
});
}
export function runAgentAttempt(params: {
providerOverride: string;
modelOverride: string;

View File

@@ -1,4 +1,5 @@
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import "./agent-command.test-mocks.js";
@@ -49,6 +50,19 @@ function readSessionStore<T>(storePath: string): Record<string, T> {
return JSON.parse(fs.readFileSync(storePath, "utf-8")) as Record<string, T>;
}
async function readSessionMessages(sessionFile: string) {
const raw = await fsp.readFile(sessionFile, "utf-8");
return raw
.split(/\r?\n/)
.filter(Boolean)
.map((line) => JSON.parse(line) as { type?: string; message?: unknown })
.filter((entry) => entry.type === "message")
.map(
(entry) =>
entry.message as { role?: string; content?: unknown; provider?: string; model?: string },
);
}
function expectLastEmbeddedProviderModel(provider: string, model: string): void {
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
expect(callArgs?.provider).toBe(provider);
@@ -140,6 +154,62 @@ describe("agentCommand CLI provider handling", () => {
}
});
it("persists successful google-gemini-cli replies into the session transcript", async () => {
vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(
(provider) => provider.trim().toLowerCase() === "google-gemini-cli",
);
try {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
const sessionKey = "agent:main:subagent:gemini-cli-transcript";
mockConfig(home, store, {
model: { primary: "google-gemini-cli/gemini-3.1-pro-preview", fallbacks: [] },
models: { "google-gemini-cli/gemini-3.1-pro-preview": {} },
});
runCliAgentSpy.mockResolvedValueOnce({
payloads: [{ text: "hello from cli" }],
meta: {
durationMs: 5,
finalAssistantVisibleText: "hello from cli",
agentMeta: {
sessionId: "cli-session-123",
provider: "google-gemini-cli",
model: "gemini-3.1-pro-preview",
},
executionTrace: {
winnerProvider: "google-gemini-cli",
winnerModel: "gemini-3.1-pro-preview",
fallbackUsed: false,
runner: "cli",
},
},
} as ReturnType<typeof createDefaultAgentCommandResult>);
await agentCommand({ message: "persist this", sessionKey }, runtime);
const saved = readSessionStore<{ sessionFile?: string }>(store);
const sessionFile = saved[sessionKey]?.sessionFile;
expect(sessionFile).toBeTruthy();
const messages = await readSessionMessages(sessionFile!);
expect(messages).toHaveLength(2);
expect(messages[0]).toMatchObject({
role: "user",
content: "persist this",
});
expect(messages[1]).toMatchObject({
role: "assistant",
provider: "google-gemini-cli",
model: "gemini-3.1-pro-preview",
content: [{ type: "text", text: "hello from cli" }],
});
});
} finally {
vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(() => false);
}
});
it("clears stale Claude CLI legacy session IDs before retrying after session expiration", async () => {
vi.mocked(modelSelectionModule.isCliProvider).mockImplementation(
(provider) => provider.trim().toLowerCase() === "claude-cli",