diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index 29fa37e94ad..3ffcfc8d168 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -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({ diff --git a/src/agents/command/attempt-execution.runtime.ts b/src/agents/command/attempt-execution.runtime.ts index 5c9e9d2a44b..495142f204f 100644 --- a/src/agents/command/attempt-execution.runtime.ts +++ b/src/agents/command/attempt-execution.runtime.ts @@ -6,6 +6,7 @@ export { emitAcpLifecycleError, emitAcpLifecycleStart, persistAcpTurnTranscript, + persistCliTurnTranscript, runAgentAttempt, sessionFileHasContent, } from "./attempt-execution.js"; diff --git a/src/agents/command/attempt-execution.ts b/src/agents/command/attempt-execution.ts index b191fba86e9..8b36d780e90 100644 --- a/src/agents/command/attempt-execution.ts +++ b/src/agents/command/attempt-execution.ts @@ -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 { + 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 { 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; + storePath?: string; + sessionAgentId: string; + threadId?: string | number; + sessionCwd: string; +}): Promise { + 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; + storePath?: string; + sessionAgentId: string; + threadId?: string | number; + sessionCwd: string; +}): Promise { + 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; diff --git a/src/commands/agent.cli-provider.test.ts b/src/commands/agent.cli-provider.test.ts index a35e1c4c939..453c6536f1b 100644 --- a/src/commands/agent.cli-provider.test.ts +++ b/src/commands/agent.cli-provider.test.ts @@ -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(storePath: string): Record { return JSON.parse(fs.readFileSync(storePath, "utf-8")) as Record; } +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); + + 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",