mirror of https://github.com/openclaw/openclaw.git
fix(acp): persist spawned child session history (#40137)
Merged via squash.
Prepared head SHA: 62de5d5669
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
parent
72ebaf97c3
commit
404b1527e6
|
|
@ -10198,21 +10198,21 @@
|
|||
"filename": "docs/tools/web.md",
|
||||
"hashed_secret": "6b26c117c66a0c030e239eef595c1e18865132a8",
|
||||
"is_verified": false,
|
||||
"line_number": 131
|
||||
"line_number": 135
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/tools/web.md",
|
||||
"hashed_secret": "491d458f895b9213facb2ee9375b1b044eaea3ac",
|
||||
"is_verified": false,
|
||||
"line_number": 224
|
||||
"line_number": 228
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/tools/web.md",
|
||||
"hashed_secret": "674397e2c0c2faaa85961c708d2a96a7cc7af217",
|
||||
"is_verified": false,
|
||||
"line_number": 328
|
||||
"line_number": 332
|
||||
}
|
||||
],
|
||||
"docs/tts.md": [
|
||||
|
|
@ -13034,5 +13034,5 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"generated_at": "2026-03-08T18:14:00Z"
|
||||
"generated_at": "2026-03-08T18:30:57Z"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai
|
|||
- Tools/web search: restore Perplexity OpenRouter/Sonar compatibility for legacy `OPENROUTER_API_KEY`, `sk-or-...`, and explicit `perplexity.baseUrl` / `model` setups while keeping direct Perplexity keys on the native Search API path. (#39937) Thanks @obviyus.
|
||||
- Hooks/session-memory: keep `/new` and `/reset` memory artifacts in the bound agent workspace and align saved reset session keys with that workspace when stale main-agent keys leak into the hook path. (#39875) thanks @rbutera.
|
||||
- Sessions/model switch: clear stale cached `contextTokens` when a session changes models so status and runtime paths recompute against the active model window. (#38044) thanks @yuweuii.
|
||||
- ACP/session history: persist transcripts for successful ACP child runs, preserve exact transcript text, record ACP spawned-session lineage, and keep spawn-time transcript-path persistence best-effort so history storage failures do not block execution. (#40137) thanks @mbelinky.
|
||||
|
||||
## 2026.3.7
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@ const hoisted = vi.hoisted(() => {
|
|||
const initializeSessionMock = vi.fn();
|
||||
const startAcpSpawnParentStreamRelayMock = vi.fn();
|
||||
const resolveAcpSpawnStreamLogPathMock = vi.fn();
|
||||
const loadSessionStoreMock = vi.fn();
|
||||
const resolveStorePathMock = vi.fn();
|
||||
const resolveSessionTranscriptFileMock = vi.fn();
|
||||
const state = {
|
||||
cfg: createDefaultSpawnConfig(),
|
||||
};
|
||||
|
|
@ -49,6 +52,9 @@ const hoisted = vi.hoisted(() => {
|
|||
initializeSessionMock,
|
||||
startAcpSpawnParentStreamRelayMock,
|
||||
resolveAcpSpawnStreamLogPathMock,
|
||||
loadSessionStoreMock,
|
||||
resolveStorePathMock,
|
||||
resolveSessionTranscriptFileMock,
|
||||
state,
|
||||
};
|
||||
});
|
||||
|
|
@ -86,6 +92,24 @@ vi.mock("../gateway/call.js", () => ({
|
|||
callGateway: (opts: unknown) => hoisted.callGatewayMock(opts),
|
||||
}));
|
||||
|
||||
vi.mock("../config/sessions.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/sessions.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadSessionStore: (storePath: string) => hoisted.loadSessionStoreMock(storePath),
|
||||
resolveStorePath: (store: unknown, opts: unknown) => hoisted.resolveStorePathMock(store, opts),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../config/sessions/transcript.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/sessions/transcript.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveSessionTranscriptFile: (params: unknown) =>
|
||||
hoisted.resolveSessionTranscriptFileMock(params),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../acp/control-plane/manager.js", () => {
|
||||
return {
|
||||
getAcpSessionManager: () => ({
|
||||
|
|
@ -263,6 +287,34 @@ describe("spawnAcpDirect", () => {
|
|||
hoisted.resolveAcpSpawnStreamLogPathMock
|
||||
.mockReset()
|
||||
.mockReturnValue("/tmp/sess-main.acp-stream.jsonl");
|
||||
hoisted.resolveStorePathMock.mockReset().mockReturnValue("/tmp/codex-sessions.json");
|
||||
hoisted.loadSessionStoreMock.mockReset().mockImplementation(() => {
|
||||
const store: Record<string, { sessionId: string; updatedAt: number }> = {};
|
||||
return new Proxy(store, {
|
||||
get(_target, prop) {
|
||||
if (typeof prop === "string" && prop.startsWith("agent:codex:acp:")) {
|
||||
return { sessionId: "sess-123", updatedAt: Date.now() };
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
});
|
||||
hoisted.resolveSessionTranscriptFileMock
|
||||
.mockReset()
|
||||
.mockImplementation(async (params: unknown) => {
|
||||
const typed = params as { threadId?: string };
|
||||
const sessionFile = typed.threadId
|
||||
? `/tmp/agents/codex/sessions/sess-123-topic-${typed.threadId}.jsonl`
|
||||
: "/tmp/agents/codex/sessions/sess-123.jsonl";
|
||||
return {
|
||||
sessionFile,
|
||||
sessionEntry: {
|
||||
sessionId: "sess-123",
|
||||
updatedAt: Date.now(),
|
||||
sessionFile,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
it("spawns ACP session, binds a new thread, and dispatches initial task", async () => {
|
||||
|
|
@ -286,6 +338,13 @@ describe("spawnAcpDirect", () => {
|
|||
expect(result.childSessionKey).toMatch(/^agent:codex:acp:/);
|
||||
expect(result.runId).toBe("run-1");
|
||||
expect(result.mode).toBe("session");
|
||||
const patchCalls = hoisted.callGatewayMock.mock.calls
|
||||
.map((call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> })
|
||||
.filter((request) => request.method === "sessions.patch");
|
||||
expect(patchCalls[0]?.params).toMatchObject({
|
||||
key: result.childSessionKey,
|
||||
spawnedBy: "agent:main:main",
|
||||
});
|
||||
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
targetKind: "session",
|
||||
|
|
@ -308,6 +367,12 @@ describe("spawnAcpDirect", () => {
|
|||
mode: "persistent",
|
||||
}),
|
||||
);
|
||||
const transcriptCalls = hoisted.resolveSessionTranscriptFileMock.mock.calls.map(
|
||||
(call: unknown[]) => call[0] as { threadId?: string },
|
||||
);
|
||||
expect(transcriptCalls).toHaveLength(2);
|
||||
expect(transcriptCalls[0]?.threadId).toBeUndefined();
|
||||
expect(transcriptCalls[1]?.threadId).toBe("child-thread");
|
||||
});
|
||||
|
||||
it("does not inline delivery for fresh oneshot ACP runs", async () => {
|
||||
|
|
@ -328,6 +393,13 @@ describe("spawnAcpDirect", () => {
|
|||
|
||||
expect(result.status).toBe("accepted");
|
||||
expect(result.mode).toBe("run");
|
||||
expect(hoisted.resolveSessionTranscriptFileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: "sess-123",
|
||||
storePath: "/tmp/codex-sessions.json",
|
||||
agentId: "codex",
|
||||
}),
|
||||
);
|
||||
const agentCall = hoisted.callGatewayMock.mock.calls
|
||||
.map((call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> })
|
||||
.find((request) => request.method === "agent");
|
||||
|
|
@ -337,6 +409,32 @@ describe("spawnAcpDirect", () => {
|
|||
expect(agentCall?.params?.threadId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps ACP spawn running when session-file persistence fails", async () => {
|
||||
hoisted.resolveSessionTranscriptFileMock.mockRejectedValueOnce(new Error("disk full"));
|
||||
|
||||
const result = await spawnAcpDirect(
|
||||
{
|
||||
task: "Investigate flaky tests",
|
||||
agentId: "codex",
|
||||
mode: "run",
|
||||
},
|
||||
{
|
||||
agentSessionKey: "agent:main:main",
|
||||
agentChannel: "telegram",
|
||||
agentAccountId: "default",
|
||||
agentTo: "telegram:6098642967",
|
||||
agentThreadId: "1",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe("accepted");
|
||||
expect(result.childSessionKey).toMatch(/^agent:codex:acp:/);
|
||||
const agentCall = hoisted.callGatewayMock.mock.calls
|
||||
.map((call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> })
|
||||
.find((request) => request.method === "agent");
|
||||
expect(agentCall?.params?.sessionKey).toBe(result.childSessionKey);
|
||||
});
|
||||
|
||||
it("includes cwd in ACP thread intro banner when provided at spawn time", async () => {
|
||||
const result = await spawnAcpDirect(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ import {
|
|||
} from "../channels/thread-bindings-policy.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { loadSessionStore, resolveStorePath, type SessionEntry } from "../config/sessions.js";
|
||||
import { resolveSessionTranscriptFile } from "../config/sessions/transcript.js";
|
||||
import { callGateway } from "../gateway/call.js";
|
||||
import { resolveConversationIdFromTargets } from "../infra/outbound/conversation-id.js";
|
||||
import {
|
||||
|
|
@ -30,6 +32,7 @@ import {
|
|||
isSessionBindingError,
|
||||
type SessionBindingRecord,
|
||||
} from "../infra/outbound/session-binding-service.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import { normalizeDeliveryContext } from "../utils/delivery-context.js";
|
||||
import {
|
||||
|
|
@ -38,6 +41,9 @@ import {
|
|||
startAcpSpawnParentStreamRelay,
|
||||
} from "./acp-spawn-parent-stream.js";
|
||||
import { resolveSandboxRuntimeStatus } from "./sandbox/runtime-status.js";
|
||||
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./tools/sessions-helpers.js";
|
||||
|
||||
const log = createSubsystemLogger("agents/acp-spawn");
|
||||
|
||||
export const ACP_SPAWN_MODES = ["run", "session"] as const;
|
||||
export type SpawnAcpMode = (typeof ACP_SPAWN_MODES)[number];
|
||||
|
|
@ -162,6 +168,50 @@ function summarizeError(err: unknown): string {
|
|||
return "error";
|
||||
}
|
||||
|
||||
function resolveRequesterInternalSessionKey(params: {
|
||||
cfg: OpenClawConfig;
|
||||
requesterSessionKey?: string;
|
||||
}): string {
|
||||
const { mainKey, alias } = resolveMainSessionAlias(params.cfg);
|
||||
const requesterSessionKey = params.requesterSessionKey?.trim();
|
||||
return requesterSessionKey
|
||||
? resolveInternalSessionKey({
|
||||
key: requesterSessionKey,
|
||||
alias,
|
||||
mainKey,
|
||||
})
|
||||
: alias;
|
||||
}
|
||||
|
||||
async function persistAcpSpawnSessionFileBestEffort(params: {
|
||||
sessionId: string;
|
||||
sessionKey: string;
|
||||
sessionEntry: SessionEntry | undefined;
|
||||
sessionStore: Record<string, SessionEntry>;
|
||||
storePath: string;
|
||||
agentId: string;
|
||||
threadId?: string | number;
|
||||
stage: "spawn" | "thread-bind";
|
||||
}): Promise<SessionEntry | undefined> {
|
||||
try {
|
||||
const resolvedSessionFile = await resolveSessionTranscriptFile({
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionEntry: params.sessionEntry,
|
||||
sessionStore: params.sessionStore,
|
||||
storePath: params.storePath,
|
||||
agentId: params.agentId,
|
||||
threadId: params.threadId,
|
||||
});
|
||||
return resolvedSessionFile.sessionEntry;
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
`ACP session-file persistence failed during ${params.stage} for ${params.sessionKey}: ${summarizeError(error)}`,
|
||||
);
|
||||
return params.sessionEntry;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveConversationIdForThreadBinding(params: {
|
||||
to?: string;
|
||||
threadId?: string | number;
|
||||
|
|
@ -257,6 +307,10 @@ export async function spawnAcpDirect(
|
|||
ctx: SpawnAcpContext,
|
||||
): Promise<SpawnAcpResult> {
|
||||
const cfg = loadConfig();
|
||||
const requesterInternalKey = resolveRequesterInternalSessionKey({
|
||||
cfg,
|
||||
requesterSessionKey: ctx.agentSessionKey,
|
||||
});
|
||||
if (!isAcpEnabledByPolicy(cfg)) {
|
||||
return {
|
||||
status: "forbidden",
|
||||
|
|
@ -346,11 +400,27 @@ export async function spawnAcpDirect(
|
|||
method: "sessions.patch",
|
||||
params: {
|
||||
key: sessionKey,
|
||||
spawnedBy: requesterInternalKey,
|
||||
...(params.label ? { label: params.label } : {}),
|
||||
},
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
sessionCreated = true;
|
||||
const storePath = resolveStorePath(cfg.session?.store, { agentId: targetAgentId });
|
||||
const sessionStore = loadSessionStore(storePath);
|
||||
let sessionEntry: SessionEntry | undefined = sessionStore[sessionKey];
|
||||
const sessionId = sessionEntry?.sessionId;
|
||||
if (sessionId) {
|
||||
sessionEntry = await persistAcpSpawnSessionFileBestEffort({
|
||||
sessionId,
|
||||
sessionKey,
|
||||
sessionStore,
|
||||
storePath,
|
||||
sessionEntry,
|
||||
agentId: targetAgentId,
|
||||
stage: "spawn",
|
||||
});
|
||||
}
|
||||
const initialized = await acpManager.initializeSession({
|
||||
cfg,
|
||||
sessionKey,
|
||||
|
|
@ -408,6 +478,21 @@ export async function spawnAcpDirect(
|
|||
`Failed to create and bind a ${preparedBinding.channel} thread for this ACP session.`,
|
||||
);
|
||||
}
|
||||
if (sessionId) {
|
||||
const boundThreadId = String(binding.conversation.conversationId).trim() || undefined;
|
||||
if (boundThreadId) {
|
||||
sessionEntry = await persistAcpSpawnSessionFileBestEffort({
|
||||
sessionId,
|
||||
sessionKey,
|
||||
sessionStore,
|
||||
storePath,
|
||||
sessionEntry,
|
||||
agentId: targetAgentId,
|
||||
threadId: boundThreadId,
|
||||
stage: "thread-bind",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
await cleanupFailedAcpSpawn({
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { AcpRuntimeError } from "../acp/runtime/errors.js";
|
|||
import * as embeddedModule from "../agents/pi-embedded.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import * as configModule from "../config/config.js";
|
||||
import { readSessionMessages } from "../gateway/session-utils.fs.js";
|
||||
import { onAgentEvent } from "../infra/agent-events.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { agentCommand } from "./agent.js";
|
||||
|
|
@ -133,6 +134,17 @@ async function withAcpSessionEnv(fn: () => Promise<void>) {
|
|||
});
|
||||
}
|
||||
|
||||
async function withAcpSessionEnvInfo(
|
||||
fn: (env: { home: string; storePath: string }) => Promise<void>,
|
||||
) {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
writeAcpSessionStore(storePath);
|
||||
mockConfig(home, storePath);
|
||||
await fn({ home, storePath });
|
||||
});
|
||||
}
|
||||
|
||||
function createRunTurnFromTextDeltas(chunks: string[]) {
|
||||
return vi.fn(async (paramsUnknown: unknown) => {
|
||||
const params = paramsUnknown as {
|
||||
|
|
@ -220,6 +232,62 @@ describe("agentCommand ACP runtime routing", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("persists ACP child session history to the transcript store", async () => {
|
||||
await withAcpSessionEnvInfo(async ({ storePath }) => {
|
||||
const runTurn = createRunTurnFromTextDeltas(["ACP_", "OK"]);
|
||||
|
||||
mockAcpManager({
|
||||
runTurn: (params: unknown) => runTurn(params),
|
||||
});
|
||||
|
||||
await agentCommand({ message: "ping", sessionKey: "agent:codex:acp:test" }, runtime);
|
||||
|
||||
const persistedStore = JSON.parse(fs.readFileSync(storePath, "utf-8")) as Record<
|
||||
string,
|
||||
{ sessionFile?: string }
|
||||
>;
|
||||
const sessionFile = persistedStore["agent:codex:acp:test"]?.sessionFile;
|
||||
const messages = readSessionMessages("acp-session-1", storePath, sessionFile);
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toMatchObject({
|
||||
role: "user",
|
||||
content: "ping",
|
||||
});
|
||||
expect(messages[1]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "ACP_OK" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves exact ACP transcript text without trimming whitespace", async () => {
|
||||
await withAcpSessionEnvInfo(async ({ storePath }) => {
|
||||
const runTurn = createRunTurnFromTextDeltas([" ACP_OK\n"]);
|
||||
|
||||
mockAcpManager({
|
||||
runTurn: (params: unknown) => runTurn(params),
|
||||
});
|
||||
|
||||
await agentCommand({ message: " ping\n", sessionKey: "agent:codex:acp:test" }, runtime);
|
||||
|
||||
const persistedStore = JSON.parse(fs.readFileSync(storePath, "utf-8")) as Record<
|
||||
string,
|
||||
{ sessionFile?: string }
|
||||
>;
|
||||
const sessionFile = persistedStore["agent:codex:acp:test"]?.sessionFile;
|
||||
const messages = readSessionMessages("acp-session-1", storePath, sessionFile);
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toMatchObject({
|
||||
role: "user",
|
||||
content: " ping\n",
|
||||
});
|
||||
expect(messages[1]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: " ACP_OK\n" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses ACP NO_REPLY lead fragments before emitting assistant text", async () => {
|
||||
await withAcpSessionEnv(async () => {
|
||||
const { assistantEvents, stop } = subscribeAssistantEvents();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import fs from "node:fs/promises";
|
||||
import { SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
import { getAcpSessionManager } from "../acp/control-plane/manager.js";
|
||||
import { resolveAcpAgentPolicyError, resolveAcpDispatchPolicyError } from "../acp/policy.js";
|
||||
import { toAcpRuntimeError } from "../acp/runtime/errors.js";
|
||||
import { resolveAcpSessionCwd } from "../acp/runtime/session-identifiers.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
|
||||
const log = createSubsystemLogger("commands/agent");
|
||||
|
|
@ -33,6 +36,7 @@ import {
|
|||
resolveDefaultModelForAgent,
|
||||
resolveThinkingDefault,
|
||||
} from "../agents/model-selection.js";
|
||||
import { prepareSessionManagerForRun } from "../agents/pi-embedded-runner/session-manager-init.js";
|
||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
|
||||
import { getSkillsSnapshotVersion } from "../agents/skills/refresh.js";
|
||||
|
|
@ -65,15 +69,11 @@ import {
|
|||
} from "../config/config.js";
|
||||
import {
|
||||
mergeSessionEntry,
|
||||
parseSessionThreadInfo,
|
||||
resolveAndPersistSessionFile,
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveSessionFilePath,
|
||||
resolveSessionFilePathOptions,
|
||||
resolveSessionTranscriptPath,
|
||||
type SessionEntry,
|
||||
updateSessionStore,
|
||||
} from "../config/sessions.js";
|
||||
import { resolveSessionTranscriptFile } from "../config/sessions/transcript.js";
|
||||
import {
|
||||
clearAgentRunContext,
|
||||
emitAgentEvent,
|
||||
|
|
@ -86,6 +86,7 @@ import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
|||
import { applyVerboseOverride } from "../sessions/level-overrides.js";
|
||||
import { applyModelOverrideToSessionEntry } from "../sessions/model-overrides.js";
|
||||
import { resolveSendPolicy } from "../sessions/send-policy.js";
|
||||
import { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js";
|
||||
import { resolveMessageChannel } from "../utils/message-channel.js";
|
||||
import { deliverAgentCommandResult } from "./agent/delivery.js";
|
||||
import { resolveAgentRunContext } from "./agent/run-context.js";
|
||||
|
|
@ -230,9 +231,92 @@ function createAcpVisibleTextAccumulator() {
|
|||
finalize(): string {
|
||||
return visibleText.trim();
|
||||
},
|
||||
finalizeRaw(): string {
|
||||
return visibleText;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const ACP_TRANSCRIPT_USAGE = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
total: 0,
|
||||
},
|
||||
} as const;
|
||||
|
||||
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> {
|
||||
const promptText = params.body;
|
||||
const replyText = params.finalText;
|
||||
if (!promptText && !replyText) {
|
||||
return params.sessionEntry;
|
||||
}
|
||||
|
||||
const { sessionFile, sessionEntry } = await resolveSessionTranscriptFile({
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionEntry: params.sessionEntry,
|
||||
sessionStore: params.sessionStore,
|
||||
storePath: params.storePath,
|
||||
agentId: params.sessionAgentId,
|
||||
threadId: params.threadId,
|
||||
});
|
||||
const hadSessionFile = await fs
|
||||
.access(sessionFile)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
await prepareSessionManagerForRun({
|
||||
sessionManager,
|
||||
sessionFile,
|
||||
hadSessionFile,
|
||||
sessionId: params.sessionId,
|
||||
cwd: params.sessionCwd,
|
||||
});
|
||||
|
||||
if (promptText) {
|
||||
sessionManager.appendMessage({
|
||||
role: "user",
|
||||
content: promptText,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
if (replyText) {
|
||||
sessionManager.appendMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: replyText }],
|
||||
api: "openai-responses",
|
||||
provider: "openclaw",
|
||||
model: "acp-runtime",
|
||||
usage: ACP_TRANSCRIPT_USAGE,
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
emitSessionTranscriptUpdate(sessionFile);
|
||||
return sessionEntry;
|
||||
}
|
||||
|
||||
function runAgentAttempt(params: {
|
||||
providerOverride: string;
|
||||
modelOverride: string;
|
||||
|
|
@ -421,8 +505,8 @@ async function prepareAgentCommandExecution(
|
|||
opts: AgentCommandOpts & { senderIsOwner: boolean },
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
const message = (opts.message ?? "").trim();
|
||||
if (!message) {
|
||||
const message = opts.message ?? "";
|
||||
if (!message.trim()) {
|
||||
throw new Error("Message (--message) is required");
|
||||
}
|
||||
const body = prependInternalEventContext(message, opts.internalEvents);
|
||||
|
|
@ -732,8 +816,29 @@ async function agentCommandInternal(
|
|||
},
|
||||
});
|
||||
|
||||
const finalTextRaw = visibleTextAccumulator.finalizeRaw();
|
||||
const finalText = visibleTextAccumulator.finalize();
|
||||
try {
|
||||
sessionEntry = await persistAcpTurnTranscript({
|
||||
body,
|
||||
finalText: finalTextRaw,
|
||||
sessionId,
|
||||
sessionKey,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
storePath,
|
||||
sessionAgentId,
|
||||
threadId: opts.threadId,
|
||||
sessionCwd: resolveAcpSessionCwd(acpResolution.meta) ?? workspaceDir,
|
||||
});
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
`ACP transcript persistence failed for ${sessionKey}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedFinalPayload = normalizeReplyPayload({
|
||||
text: visibleTextAccumulator.finalize(),
|
||||
text: finalText,
|
||||
});
|
||||
const payloads = normalizedFinalPayload ? [normalizedFinalPayload] : [];
|
||||
const result = {
|
||||
|
|
@ -944,29 +1049,27 @@ async function agentCommandInternal(
|
|||
});
|
||||
}
|
||||
}
|
||||
const sessionPathOpts = resolveSessionFilePathOptions({
|
||||
agentId: sessionAgentId,
|
||||
storePath,
|
||||
});
|
||||
let sessionFile = resolveSessionFilePath(sessionId, sessionEntry, sessionPathOpts);
|
||||
let sessionFile: string | undefined;
|
||||
if (sessionStore && sessionKey) {
|
||||
const threadIdFromSessionKey = parseSessionThreadInfo(sessionKey).threadId;
|
||||
const fallbackSessionFile = !sessionEntry?.sessionFile
|
||||
? resolveSessionTranscriptPath(
|
||||
sessionId,
|
||||
sessionAgentId,
|
||||
opts.threadId ?? threadIdFromSessionKey,
|
||||
)
|
||||
: undefined;
|
||||
const resolvedSessionFile = await resolveAndPersistSessionFile({
|
||||
const resolvedSessionFile = await resolveSessionTranscriptFile({
|
||||
sessionId,
|
||||
sessionKey,
|
||||
sessionStore,
|
||||
storePath,
|
||||
sessionEntry,
|
||||
agentId: sessionPathOpts?.agentId,
|
||||
sessionsDir: sessionPathOpts?.sessionsDir,
|
||||
fallbackSessionFile,
|
||||
agentId: sessionAgentId,
|
||||
threadId: opts.threadId,
|
||||
});
|
||||
sessionFile = resolvedSessionFile.sessionFile;
|
||||
sessionEntry = resolvedSessionFile.sessionEntry;
|
||||
}
|
||||
if (!sessionFile) {
|
||||
const resolvedSessionFile = await resolveSessionTranscriptFile({
|
||||
sessionId,
|
||||
sessionKey: sessionKey ?? sessionId,
|
||||
sessionEntry,
|
||||
agentId: sessionAgentId,
|
||||
threadId: opts.threadId,
|
||||
});
|
||||
sessionFile = resolvedSessionFile.sessionFile;
|
||||
sessionEntry = resolvedSessionFile.sessionEntry;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,13 @@ import fs from "node:fs";
|
|||
import path from "node:path";
|
||||
import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
|
||||
import { resolveDefaultSessionStorePath } from "./paths.js";
|
||||
import { parseSessionThreadInfo } from "./delivery-info.js";
|
||||
import {
|
||||
resolveDefaultSessionStorePath,
|
||||
resolveSessionFilePath,
|
||||
resolveSessionFilePathOptions,
|
||||
resolveSessionTranscriptPath,
|
||||
} from "./paths.js";
|
||||
import { resolveAndPersistSessionFile } from "./session-file.js";
|
||||
import { loadSessionStore } from "./store.js";
|
||||
import type { SessionEntry } from "./types.js";
|
||||
|
|
@ -79,6 +85,51 @@ async function ensureSessionHeader(params: {
|
|||
});
|
||||
}
|
||||
|
||||
export async function resolveSessionTranscriptFile(params: {
|
||||
sessionId: string;
|
||||
sessionKey: string;
|
||||
sessionEntry: SessionEntry | undefined;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
storePath?: string;
|
||||
agentId: string;
|
||||
threadId?: string | number;
|
||||
}): Promise<{ sessionFile: string; sessionEntry: SessionEntry | undefined }> {
|
||||
const sessionPathOpts = resolveSessionFilePathOptions({
|
||||
agentId: params.agentId,
|
||||
storePath: params.storePath,
|
||||
});
|
||||
let sessionFile = resolveSessionFilePath(params.sessionId, params.sessionEntry, sessionPathOpts);
|
||||
let sessionEntry = params.sessionEntry;
|
||||
|
||||
if (params.sessionStore && params.storePath) {
|
||||
const threadIdFromSessionKey = parseSessionThreadInfo(params.sessionKey).threadId;
|
||||
const fallbackSessionFile = !sessionEntry?.sessionFile
|
||||
? resolveSessionTranscriptPath(
|
||||
params.sessionId,
|
||||
params.agentId,
|
||||
params.threadId ?? threadIdFromSessionKey,
|
||||
)
|
||||
: undefined;
|
||||
const resolvedSessionFile = await resolveAndPersistSessionFile({
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionStore: params.sessionStore,
|
||||
storePath: params.storePath,
|
||||
sessionEntry,
|
||||
agentId: sessionPathOpts?.agentId,
|
||||
sessionsDir: sessionPathOpts?.sessionsDir,
|
||||
fallbackSessionFile,
|
||||
});
|
||||
sessionFile = resolvedSessionFile.sessionFile;
|
||||
sessionEntry = resolvedSessionFile.sessionEntry;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionFile,
|
||||
sessionEntry,
|
||||
};
|
||||
}
|
||||
|
||||
export async function appendAssistantMessageToSessionTranscript(params: {
|
||||
agentId?: string;
|
||||
sessionKey: string;
|
||||
|
|
|
|||
Loading…
Reference in New Issue