Skip to content

Commit 4b60514

Browse files
fix(agents): persist user turn before attempt failures
1 parent 1b11740 commit 4b60514

4 files changed

Lines changed: 431 additions & 52 deletions

File tree

src/agents/agent-command.ts

Lines changed: 122 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1174,6 +1174,12 @@ async function agentCommandInternal(
11741174
let stopReason: string | undefined;
11751175
let resultStatus: "completed" | "cancelled" | undefined;
11761176
let terminalOutcome: "blocked" | undefined;
1177+
let acpSessionCwd = workspaceDir;
1178+
let acpInternalTarget:
1179+
| Awaited<ReturnType<typeof prepareInternalSessionEffectsSession>>
1180+
| undefined;
1181+
let acpUserTurnHandled: boolean;
1182+
let acpTranscriptPersistenceBlocked = false;
11771183
try {
11781184
const {
11791185
resolveAcpAgentPolicyError,
@@ -1197,6 +1203,86 @@ async function agentCommandInternal(
11971203
throw agentPolicyError;
11981204
}
11991205

1206+
try {
1207+
const { resolveAcpSessionCwd } = await loadAcpSessionIdentifiersRuntime();
1208+
acpSessionCwd = resolveAcpSessionCwd(acpResolution.meta) ?? workspaceDir;
1209+
const internalSource = suppressVisibleSessionEffects
1210+
? resolveInternalSessionEffectsSource({
1211+
agentId: sessionAgentId,
1212+
sessionId,
1213+
sessionKey,
1214+
storePath,
1215+
})
1216+
: undefined;
1217+
acpInternalTarget = suppressVisibleSessionEffects
1218+
? await prepareInternalSessionEffectsSession({
1219+
agentId: sessionAgentId,
1220+
cwd: acpSessionCwd,
1221+
runId,
1222+
source: internalSource,
1223+
storePath,
1224+
})
1225+
: undefined;
1226+
trackInternalModelRunTarget(acpInternalTarget);
1227+
const transcriptMedia = opts.transcriptMedia ?? [];
1228+
const suppressUserTurnPersistence =
1229+
opts.suppressPromptPersistence === true ||
1230+
(opts.transcriptMessage === "" && transcriptMedia.length === 0);
1231+
const userTurnTranscriptRecorder = createUserTurnTranscriptRecorder({
1232+
...(!suppressUserTurnPersistence && (transcriptBody || transcriptMedia.length > 0)
1233+
? {
1234+
input: {
1235+
text: transcriptBody,
1236+
...(transcriptMedia.length > 0
1237+
? {
1238+
media: transcriptMedia,
1239+
mediaOnlyText: "[User sent media without caption]",
1240+
}
1241+
: {}),
1242+
},
1243+
}
1244+
: {}),
1245+
target: {
1246+
sessionId: acpInternalTarget?.sessionId ?? sessionId,
1247+
...(!acpInternalTarget ? { expectedSessionId: sessionId } : {}),
1248+
agentId: acpInternalTarget?.agentId ?? sessionAgentId,
1249+
sessionKey: acpInternalTarget?.sessionKey ?? sessionKey,
1250+
sessionEntry: acpInternalTarget?.sessionEntry ?? sessionEntry,
1251+
sessionStore: suppressVisibleSessionEffects ? undefined : sessionStore,
1252+
storePath: acpInternalTarget?.storePath ?? storePath,
1253+
threadId: opts.threadId,
1254+
cwd: acpSessionCwd,
1255+
config: cfg,
1256+
},
1257+
beforeMessageWrite: runAgentHarnessBeforeMessageWriteHook,
1258+
errorContext: "ACP user turn transcript",
1259+
});
1260+
if (suppressUserTurnPersistence) {
1261+
userTurnTranscriptRecorder.markBlocked();
1262+
}
1263+
const persistedUserTurn = await userTurnTranscriptRecorder.persistApproved({
1264+
cwd: acpSessionCwd,
1265+
});
1266+
if (
1267+
!persistedUserTurn &&
1268+
!userTurnTranscriptRecorder.hasPersisted() &&
1269+
(await userTurnTranscriptRecorder.resolveMessage())
1270+
) {
1271+
userTurnTranscriptRecorder.markBlocked();
1272+
}
1273+
acpUserTurnHandled =
1274+
userTurnTranscriptRecorder.hasPersisted() || userTurnTranscriptRecorder.isBlocked();
1275+
if (!acpInternalTarget && persistedUserTurn?.sessionEntry) {
1276+
sessionEntry = persistedUserTurn.sessionEntry;
1277+
}
1278+
} catch (error) {
1279+
acpUserTurnHandled = false;
1280+
acpTranscriptPersistenceBlocked = suppressVisibleSessionEffects && !acpInternalTarget;
1281+
log.warn(
1282+
`ACP pre-turn transcript persistence failed for ${sessionKey}: ${formatErrorMessage(error)}`,
1283+
);
1284+
}
1285+
12001286
const acpImageAttachments = resolveInlineAgentImageAttachments(opts.images);
12011287
assertAgentRunLifecycleGenerationCurrent(lifecycleGeneration);
12021288
await acpManager.runTurn({
@@ -1278,57 +1364,43 @@ async function agentCommandInternal(
12781364

12791365
const finalTextRaw = visibleTextAccumulator.finalizeRaw();
12801366
const finalText = visibleTextAccumulator.finalize();
1281-
try {
1282-
const { resolveAcpSessionCwd } = await loadAcpSessionIdentifiersRuntime();
1283-
const internalSource = suppressVisibleSessionEffects
1284-
? resolveInternalSessionEffectsSource({
1285-
agentId: sessionAgentId,
1286-
sessionId,
1287-
sessionKey,
1288-
storePath,
1289-
})
1290-
: undefined;
1291-
const internalTarget = suppressVisibleSessionEffects
1292-
? await prepareInternalSessionEffectsSession({
1293-
agentId: sessionAgentId,
1294-
cwd: resolveAcpSessionCwd(acpResolution.meta) ?? workspaceDir,
1295-
runId,
1296-
source: internalSource,
1297-
storePath,
1298-
})
1299-
: undefined;
1300-
trackInternalModelRunTarget(internalTarget);
1301-
const transcriptSessionEntry = internalTarget?.sessionEntry ?? sessionEntry;
1302-
const transcriptResult = await attemptExecutionRuntime.persistAcpTurnTranscript({
1303-
body,
1304-
transcriptBody,
1305-
...(opts.suppressPromptPersistence !== true && opts.transcriptMedia?.length
1306-
? {
1307-
userInput: {
1308-
text: transcriptBody,
1309-
media: opts.transcriptMedia,
1310-
mediaOnlyText: "[User sent media without caption]",
1311-
},
1312-
}
1313-
: {}),
1314-
finalText: finalTextRaw,
1315-
sessionId: internalTarget?.sessionId ?? sessionId,
1316-
sessionKey: internalTarget?.sessionKey ?? sessionKey,
1317-
sessionEntry: transcriptSessionEntry,
1318-
sessionStore: suppressVisibleSessionEffects ? undefined : sessionStore,
1319-
storePath: internalTarget?.storePath ?? storePath,
1320-
sessionAgentId: internalTarget?.agentId ?? sessionAgentId,
1321-
threadId: opts.threadId,
1322-
sessionCwd: resolveAcpSessionCwd(acpResolution.meta) ?? workspaceDir,
1323-
config: cfg,
1324-
});
1325-
if (!internalTarget) {
1326-
sessionEntry = transcriptResult.sessionEntry;
1367+
if (!acpTranscriptPersistenceBlocked) {
1368+
try {
1369+
const transcriptSessionEntry = acpInternalTarget?.sessionEntry ?? sessionEntry;
1370+
const transcriptResult = await attemptExecutionRuntime.persistAcpTurnTranscript({
1371+
body,
1372+
transcriptBody,
1373+
...(acpUserTurnHandled ? { skipUserTurn: true } : {}),
1374+
...(!acpUserTurnHandled &&
1375+
opts.suppressPromptPersistence !== true &&
1376+
opts.transcriptMedia?.length
1377+
? {
1378+
userInput: {
1379+
text: transcriptBody,
1380+
media: opts.transcriptMedia,
1381+
mediaOnlyText: "[User sent media without caption]",
1382+
},
1383+
}
1384+
: {}),
1385+
finalText: finalTextRaw,
1386+
sessionId: acpInternalTarget?.sessionId ?? sessionId,
1387+
sessionKey: acpInternalTarget?.sessionKey ?? sessionKey,
1388+
sessionEntry: transcriptSessionEntry,
1389+
sessionStore: suppressVisibleSessionEffects ? undefined : sessionStore,
1390+
storePath: acpInternalTarget?.storePath ?? storePath,
1391+
sessionAgentId: acpInternalTarget?.agentId ?? sessionAgentId,
1392+
threadId: opts.threadId,
1393+
sessionCwd: acpSessionCwd,
1394+
config: cfg,
1395+
});
1396+
if (!acpInternalTarget) {
1397+
sessionEntry = transcriptResult.sessionEntry;
1398+
}
1399+
} catch (error) {
1400+
log.warn(
1401+
`ACP transcript persistence failed for ${sessionKey}: ${formatErrorMessage(error)}`,
1402+
);
13271403
}
1328-
} catch (error) {
1329-
log.warn(
1330-
`ACP transcript persistence failed for ${sessionKey}: ${formatErrorMessage(error)}`,
1331-
);
13321404
}
13331405
const restartAbortReason = opts.abortSignal?.reason;
13341406
if (isAgentRunRestartAbortReason(restartAbortReason)) {

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

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1576,6 +1576,93 @@ describe("CLI attempt execution", () => {
15761576
);
15771577
});
15781578

1579+
it("mirrors only the ACP reply when the shared recorder already persisted the user turn", async () => {
1580+
const sessionKey = "agent:main:direct:acp-recorder-owned-user";
1581+
const sessionEntry: SessionEntry = {
1582+
sessionId: "session-acp-recorder-owned-user",
1583+
updatedAt: Date.now(),
1584+
};
1585+
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
1586+
await writeSessionStoreSeed(sessionStore);
1587+
await appendTranscriptMessage(
1588+
{ agentId: "main", sessionId: sessionEntry.sessionId, sessionKey, storePath },
1589+
{
1590+
message: {
1591+
role: "user",
1592+
content: "canonical current ask",
1593+
timestamp: Date.now(),
1594+
},
1595+
cwd: tmpDir,
1596+
},
1597+
);
1598+
1599+
await persistAcpTurnTranscript({
1600+
body: "canonical current ask",
1601+
finalText: "hello from acp",
1602+
sessionId: sessionEntry.sessionId,
1603+
sessionKey,
1604+
sessionEntry,
1605+
sessionStore,
1606+
storePath,
1607+
sessionAgentId: "main",
1608+
sessionCwd: tmpDir,
1609+
config: {},
1610+
skipUserTurn: true,
1611+
});
1612+
1613+
const messages = await readSessionMessages(
1614+
formatSqliteSessionFileMarker({
1615+
agentId: "main",
1616+
sessionId: sessionEntry.sessionId,
1617+
storePath,
1618+
}),
1619+
);
1620+
expect(messages.filter((message) => message.role === "user")).toHaveLength(1);
1621+
expect(messages).toContainEqual(
1622+
expect.objectContaining({
1623+
role: "assistant",
1624+
content: [{ type: "text", text: "hello from acp" }],
1625+
}),
1626+
);
1627+
});
1628+
1629+
it("touches the session after a recorder-owned ACP turn with no visible reply", async () => {
1630+
const sessionKey = "agent:main:direct:acp-recorder-owned-empty";
1631+
const sessionEntry: SessionEntry = {
1632+
sessionId: "session-acp-recorder-owned-empty",
1633+
updatedAt: 1,
1634+
};
1635+
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
1636+
await writeSessionStoreSeed(sessionStore);
1637+
1638+
await persistAcpTurnTranscript({
1639+
body: "canonical current ask",
1640+
finalText: "",
1641+
sessionId: sessionEntry.sessionId,
1642+
sessionKey,
1643+
sessionEntry,
1644+
sessionStore,
1645+
storePath,
1646+
sessionAgentId: "main",
1647+
sessionCwd: tmpDir,
1648+
config: {},
1649+
skipUserTurn: true,
1650+
});
1651+
1652+
const persisted = readSessionStore();
1653+
expect(persisted[sessionKey]?.updatedAt).toBeGreaterThan(1);
1654+
expect(sessionStore[sessionKey]?.updatedAt).toBe(persisted[sessionKey]?.updatedAt);
1655+
expect(
1656+
await readSessionMessages(
1657+
formatSqliteSessionFileMarker({
1658+
agentId: "main",
1659+
sessionId: sessionEntry.sessionId,
1660+
storePath,
1661+
}),
1662+
),
1663+
).toEqual([]);
1664+
});
1665+
15791666
it("persists a media-only ACP user turn when the reply is empty", async () => {
15801667
const sessionKey = "agent:main:direct:acp-media-only";
15811668
const sessionFile = path.join(tmpDir, "session-acp-media-only.jsonl");

src/agents/command/attempt-execution.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ import { formatAcpErrorChain } from "../../acp/runtime/errors.js";
1313
import { resolveAcpToolTerminalOutcome } from "../../acp/tool-status.js";
1414
import { normalizeReplyPayload } from "../../auto-reply/reply/normalize-reply.js";
1515
import type { ThinkLevel, VerboseLevel } from "../../auto-reply/thinking.js";
16-
import { persistSessionTranscriptTurn } from "../../config/sessions/session-accessor.js";
16+
import {
17+
persistSessionTranscriptTurn,
18+
updateSessionEntry,
19+
} from "../../config/sessions/session-accessor.js";
1720
import { readTailAssistantTextFromSessionTranscript } from "../../config/sessions/transcript.js";
1821
import type { SessionEntry } from "../../config/sessions/types.js";
1922
import type { OpenClawConfig } from "../../config/types.openclaw.js";
@@ -149,6 +152,7 @@ type PersistTextTurnTranscriptParams = {
149152
sessionCwd: string;
150153
config: OpenClawConfig;
151154
embeddedAssistantGapFill?: boolean;
155+
touchSessionEntryWhenEmpty?: boolean;
152156
assistant: {
153157
api: string;
154158
provider: string;
@@ -278,6 +282,29 @@ async function persistTextTurnTranscript(
278282
} as PersistedUserTurnMessage)
279283
: undefined);
280284
if (!userMessage && !replyText) {
285+
if (params.touchSessionEntryWhenEmpty) {
286+
const currentEntry = params.sessionStore?.[params.sessionKey] ?? params.sessionEntry;
287+
const nextUpdatedAt = Math.max(Date.now(), (currentEntry?.updatedAt ?? 0) + 1);
288+
if (params.storePath) {
289+
const persistedEntry = await updateSessionEntry(
290+
{ storePath: params.storePath, sessionKey: params.sessionKey },
291+
(entry) => (entry.sessionId === params.sessionId ? { updatedAt: nextUpdatedAt } : null),
292+
);
293+
if (!persistedEntry) {
294+
return { kind: "session-rebound", sessionEntry: undefined };
295+
}
296+
if (params.sessionEntry) {
297+
Object.assign(params.sessionEntry, persistedEntry);
298+
}
299+
if (params.sessionStore) {
300+
params.sessionStore[params.sessionKey] = params.sessionEntry ?? persistedEntry;
301+
}
302+
return { kind: "persisted", sessionEntry: params.sessionEntry ?? persistedEntry };
303+
}
304+
if (currentEntry?.sessionId === params.sessionId) {
305+
currentEntry.updatedAt = nextUpdatedAt;
306+
}
307+
}
281308
return { kind: "persisted", sessionEntry: params.sessionEntry };
282309
}
283310

@@ -380,15 +407,21 @@ export async function persistAcpTurnTranscript(params: {
380407
threadId?: string | number;
381408
sessionCwd: string;
382409
config: OpenClawConfig;
410+
skipUserTurn?: boolean;
383411
}): Promise<PersistTextTurnTranscriptResult> {
384412
return await persistTextTurnTranscript({
385413
...params,
386-
...(params.userInput ? { userMessage: buildPersistedUserTurnMessage(params.userInput) } : {}),
414+
body: params.skipUserTurn ? "" : params.body,
415+
transcriptBody: params.skipUserTurn ? undefined : params.transcriptBody,
416+
...(!params.skipUserTurn && params.userInput
417+
? { userMessage: buildPersistedUserTurnMessage(params.userInput) }
418+
: {}),
387419
assistant: {
388420
api: "openai-responses",
389421
provider: "openclaw",
390422
model: "acp-runtime",
391423
},
424+
touchSessionEntryWhenEmpty: params.skipUserTurn,
392425
});
393426
}
394427

@@ -432,6 +465,7 @@ export async function persistCliTurnTranscript(params: {
432465
sessionCwd: params.sessionCwd,
433466
config: params.config,
434467
embeddedAssistantGapFill: gapFill,
468+
touchSessionEntryWhenEmpty: skipUserTurn,
435469
assistant: {
436470
api: "cli",
437471
provider,

0 commit comments

Comments
 (0)