mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:10:44 +00:00
fix(agents): persist cli transcript turns
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -6,6 +6,7 @@ export {
|
||||
emitAcpLifecycleError,
|
||||
emitAcpLifecycleStart,
|
||||
persistAcpTurnTranscript,
|
||||
persistCliTurnTranscript,
|
||||
runAgentAttempt,
|
||||
sessionFileHasContent,
|
||||
} from "./attempt-execution.js";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user