Skip to content

Commit 7c08804

Browse files
fix(slack): persist delivered replies in transcripts (#92498)
Persist successful same-channel Slack and CLI assistant replies exactly once in the owning transcript. Preserve delivery-hook output, routed/runtime ownership, custom stores, and authoritative reset/session rotation bindings. Fixes #92489 Co-authored-by: Andy Ye <[email protected]>
1 parent 991471b commit 7c08804

31 files changed

Lines changed: 1332 additions & 146 deletions

src/agents/cli-runner.reliability.test.ts

Lines changed: 181 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import fs from "node:fs";
33
import os from "node:os";
44
import path from "node:path";
55
import { afterEach, describe, expect, it, vi } from "vitest";
6+
import { getReplyPayloadMetadata } from "../auto-reply/reply-payload.js";
67
import {
78
testing as replyRunTesting,
89
createReplyOperation,
@@ -1276,7 +1277,7 @@ describe("runCliAgent reliability", () => {
12761277
releaseAgentEnd();
12771278
});
12781279

1279-
it("persists approved CLI user turns before model execution", async () => {
1280+
it("persists approved CLI user turns and successful assistant output", async () => {
12801281
supervisorSpawnMock.mockResolvedValueOnce(
12811282
createManagedRun({
12821283
reason: "exit",
@@ -1289,7 +1290,7 @@ describe("runCliAgent reliability", () => {
12891290
noOutputTimedOut: false,
12901291
}),
12911292
);
1292-
const { dir, sessionFile } = createSessionFile();
1293+
const { dir, sessionFile, storePath } = createSessionFile();
12931294
const onUserMessagePersisted = vi.fn();
12941295

12951296
try {
@@ -1305,6 +1306,8 @@ describe("runCliAgent reliability", () => {
13051306
sessionFile,
13061307
workspaceDir: dir,
13071308
prompt: "runtime prompt",
1309+
persistAssistantTranscript: true,
1310+
storePath,
13081311
userTurnTranscriptRecorder: createCliUserTurnRecorder({
13091312
text: "display prompt",
13101313
sessionFile,
@@ -1316,6 +1319,9 @@ describe("runCliAgent reliability", () => {
13161319
});
13171320

13181321
expect(result.payloads).toEqual([{ text: "hello from cli" }]);
1322+
expect(getReplyPayloadMetadata(result.payloads?.[0] ?? {})).toMatchObject({
1323+
assistantTranscriptOwned: true,
1324+
});
13191325
expect(onUserMessagePersisted).toHaveBeenCalledOnce();
13201326
expect(onUserMessagePersisted).toHaveBeenCalledWith(
13211327
expect.objectContaining({
@@ -1331,12 +1337,185 @@ describe("runCliAgent reliability", () => {
13311337
content: "display prompt",
13321338
}),
13331339
);
1340+
expect(messages).toContainEqual(
1341+
expect.objectContaining({
1342+
role: "assistant",
1343+
content: [{ type: "text", text: "hello from cli" }],
1344+
api: "cli",
1345+
provider: "codex-cli",
1346+
model: "gpt-5.4",
1347+
idempotencyKey: "cli-assistant:run-persist-cli",
1348+
}),
1349+
);
13341350
expect(JSON.stringify(messages)).not.toContain("runtime prompt");
13351351
} finally {
13361352
fs.rmSync(dir, { recursive: true, force: true });
13371353
}
13381354
});
13391355

1356+
it("lets before_message_write block CLI assistant persistence without delivery fallback", async () => {
1357+
const hookRunner = {
1358+
hasHooks: vi.fn((hookName: string) => hookName === "before_message_write"),
1359+
runBeforeMessageWrite: vi.fn(() => ({ block: true })),
1360+
};
1361+
setHookRunnerForTest(hookRunner);
1362+
supervisorSpawnMock.mockResolvedValueOnce(
1363+
createManagedRun({
1364+
reason: "exit",
1365+
exitCode: 0,
1366+
exitSignal: null,
1367+
durationMs: 50,
1368+
stdout: "secret CLI output",
1369+
stderr: "",
1370+
timedOut: false,
1371+
noOutputTimedOut: false,
1372+
}),
1373+
);
1374+
const { dir, sessionFile, storePath } = createSessionFile();
1375+
1376+
try {
1377+
const context = buildPreparedContext({
1378+
sessionKey: "agent:main:main",
1379+
runId: "run-blocked-cli",
1380+
});
1381+
const result = await runPreparedCliAgent({
1382+
...context,
1383+
params: {
1384+
...context.params,
1385+
agentId: "main",
1386+
sessionFile,
1387+
workspaceDir: dir,
1388+
persistAssistantTranscript: true,
1389+
storePath,
1390+
},
1391+
});
1392+
1393+
expect(result.payloads).toEqual([{ text: "secret CLI output" }]);
1394+
expect(getReplyPayloadMetadata(result.payloads?.[0] ?? {})).toMatchObject({
1395+
assistantTranscriptOwned: true,
1396+
});
1397+
expect(readTranscriptMessages(sessionFile)).toEqual([]);
1398+
expect(hookRunner.runBeforeMessageWrite).toHaveBeenCalledOnce();
1399+
expect(
1400+
callArg(hookRunner.runBeforeMessageWrite, 0, 1, "before_message_write context"),
1401+
).toEqual({
1402+
agentId: "main",
1403+
sessionKey: "agent:main:main",
1404+
});
1405+
} finally {
1406+
fs.rmSync(dir, { recursive: true, force: true });
1407+
}
1408+
});
1409+
1410+
it("does not append late CLI output after the session key is rebound", async () => {
1411+
supervisorSpawnMock.mockResolvedValueOnce(
1412+
createManagedRun({
1413+
reason: "exit",
1414+
exitCode: 0,
1415+
exitSignal: null,
1416+
durationMs: 50,
1417+
stdout: "late CLI output",
1418+
stderr: "",
1419+
timedOut: false,
1420+
noOutputTimedOut: false,
1421+
}),
1422+
);
1423+
const { dir, sessionFile, storePath } = createSessionFile();
1424+
const replacementFile = path.join(path.dirname(sessionFile), "s2.jsonl");
1425+
fs.writeFileSync(
1426+
replacementFile,
1427+
`${JSON.stringify({
1428+
type: "session",
1429+
version: CURRENT_SESSION_VERSION,
1430+
id: "s2",
1431+
timestamp: new Date(0).toISOString(),
1432+
cwd: dir,
1433+
})}\n`,
1434+
"utf-8",
1435+
);
1436+
fs.writeFileSync(
1437+
storePath,
1438+
JSON.stringify({
1439+
"agent:main:main": {
1440+
sessionId: "s2",
1441+
sessionFile: replacementFile,
1442+
updatedAt: Date.now(),
1443+
},
1444+
}),
1445+
"utf-8",
1446+
);
1447+
1448+
try {
1449+
const context = buildPreparedContext({
1450+
sessionKey: "agent:main:main",
1451+
runId: "run-rebound-cli",
1452+
});
1453+
const result = await runPreparedCliAgent({
1454+
...context,
1455+
params: {
1456+
...context.params,
1457+
agentId: "main",
1458+
sessionFile,
1459+
workspaceDir: dir,
1460+
persistAssistantTranscript: true,
1461+
storePath,
1462+
},
1463+
});
1464+
1465+
expect(result.payloads).toEqual([{ text: "late CLI output" }]);
1466+
expect(getReplyPayloadMetadata(result.payloads?.[0] ?? {})).toMatchObject({
1467+
assistantTranscriptOwned: true,
1468+
});
1469+
expect(readTranscriptMessages(sessionFile)).toEqual([]);
1470+
expect(readTranscriptMessages(replacementFile)).toEqual([]);
1471+
} finally {
1472+
fs.rmSync(dir, { recursive: true, force: true });
1473+
}
1474+
});
1475+
1476+
it("does not persist private room-event assistant output", async () => {
1477+
supervisorSpawnMock.mockResolvedValueOnce(
1478+
createManagedRun({
1479+
reason: "exit",
1480+
exitCode: 0,
1481+
exitSignal: null,
1482+
durationMs: 50,
1483+
stdout: "private ambient output",
1484+
stderr: "",
1485+
timedOut: false,
1486+
noOutputTimedOut: false,
1487+
}),
1488+
);
1489+
const { dir, sessionFile, storePath } = createSessionFile();
1490+
1491+
try {
1492+
const context = buildPreparedContext({
1493+
sessionKey: "agent:main:main",
1494+
runId: "run-private-room-event",
1495+
});
1496+
const result = await runPreparedCliAgent({
1497+
...context,
1498+
params: {
1499+
...context.params,
1500+
agentId: "main",
1501+
sessionFile,
1502+
workspaceDir: dir,
1503+
persistAssistantTranscript: true,
1504+
storePath,
1505+
currentInboundEventKind: "room_event",
1506+
},
1507+
});
1508+
1509+
expect(result.payloads).toEqual([{ text: "private ambient output" }]);
1510+
expect(getReplyPayloadMetadata(result.payloads?.[0] ?? {})).toMatchObject({
1511+
assistantTranscriptOwned: true,
1512+
});
1513+
expect(readTranscriptMessages(sessionFile)).toEqual([]);
1514+
} finally {
1515+
fs.rmSync(dir, { recursive: true, force: true });
1516+
}
1517+
});
1518+
13401519
it("passes cwd to approved CLI user-turn persistence", async () => {
13411520
supervisorSpawnMock.mockResolvedValueOnce(
13421521
createManagedRun({

src/agents/cli-runner.spawn.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,9 @@ describe("runCliAgent spawn path", () => {
656656
currentMessageId: "reply-message-1",
657657
senderId: "sender-1",
658658
senderIsOwner: true,
659+
persistAssistantTranscript: true,
660+
storePath: "/tmp/sessions.json",
661+
currentInboundEventKind: "room_event",
659662
});
660663

661664
expect(params.messageChannel).toBe("telegram");
@@ -666,6 +669,9 @@ describe("runCliAgent spawn path", () => {
666669
expect(params.senderId).toBe("sender-1");
667670
expect(params.senderIsOwner).toBe(true);
668671
expect(params.cwd).toBe("/tmp/task-repo");
672+
expect(params.persistAssistantTranscript).toBe(true);
673+
expect(params.storePath).toBe("/tmp/sessions.json");
674+
expect(params.currentInboundEventKind).toBe("room_event");
669675
});
670676

671677
it("forwards static extra system prompt through the compat wrapper", () => {

src/agents/cli-runner.ts

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
/**
22
* Top-level CLI-backed agent runner orchestration.
33
*/
4-
import type { ReplyPayload } from "../auto-reply/reply-payload.js";
4+
import { setReplyPayloadMetadata, type ReplyPayload } from "../auto-reply/reply-payload.js";
55
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
6+
import { appendExactAssistantMessageToSessionTranscript } from "../config/sessions/transcript.js";
67
import { formatErrorMessage } from "../infra/errors.js";
78
import { createSubsystemLogger } from "../logging/subsystem.js";
89
import { buildAgentHookContextChannelFields } from "../plugins/hook-agent-context.js";
@@ -28,13 +29,15 @@ import {
2829
runHarnessContextEngineMaintenance,
2930
} from "./harness/context-engine-lifecycle.js";
3031
import { buildAgentHookContext } from "./harness/hook-context.js";
32+
import { runAgentHarnessBeforeMessageWriteHook } from "./harness/hook-helpers.js";
3133
import { buildAgentHookConversationMessages } from "./harness/hook-history.js";
3234
import {
3335
runAgentHarnessLlmInputHook,
3436
runAgentHarnessLlmOutputHook,
3537
} from "./harness/lifecycle-hook-helpers.js";
3638
import type { AgentMessage } from "./runtime/index.js";
3739
import { SessionManager } from "./sessions/session-manager.js";
40+
import { buildAssistantMessage, buildUsageWithNoCost } from "./stream-message-shared.js";
3841

3942
const log = createSubsystemLogger("agents/cli-runner");
4043

@@ -231,6 +234,62 @@ async function persistApprovedCliUserTurnTranscript(params: RunCliAgentParams):
231234
}
232235
}
233236

237+
async function persistCliAssistantTranscript(params: {
238+
runParams: RunCliAgentParams;
239+
text: string;
240+
modelId: string;
241+
usage?: {
242+
input?: number;
243+
output?: number;
244+
cacheRead?: number;
245+
cacheWrite?: number;
246+
total?: number;
247+
};
248+
}): Promise<boolean> {
249+
const { runParams } = params;
250+
if (!runParams.persistAssistantTranscript || !runParams.sessionKey || !params.text) {
251+
return false;
252+
}
253+
if (runParams.currentInboundEventKind === "room_event") {
254+
return true;
255+
}
256+
try {
257+
const result = await appendExactAssistantMessageToSessionTranscript({
258+
sessionKey: runParams.sessionKey,
259+
agentId: runParams.agentId,
260+
expectedSessionId: runParams.sessionId,
261+
storePath: runParams.storePath,
262+
idempotencyKey: `cli-assistant:${runParams.runId}`,
263+
config: runParams.config,
264+
beforeMessageWrite: runAgentHarnessBeforeMessageWriteHook,
265+
message: buildAssistantMessage({
266+
model: {
267+
api: "cli",
268+
provider: runParams.provider,
269+
id: params.modelId,
270+
},
271+
content: [{ type: "text", text: params.text }],
272+
stopReason: "stop",
273+
usage: buildUsageWithNoCost({
274+
input: params.usage?.input,
275+
output: params.usage?.output,
276+
cacheRead: params.usage?.cacheRead,
277+
cacheWrite: params.usage?.cacheWrite,
278+
totalTokens: params.usage?.total,
279+
}),
280+
}),
281+
});
282+
if (!result.ok) {
283+
log.warn(`CLI assistant transcript persistence skipped: ${result.reason}`);
284+
return result.code === "blocked" || result.code === "session-rebound";
285+
}
286+
return true;
287+
} catch (error) {
288+
log.warn(`CLI assistant transcript persistence failed: ${formatErrorMessage(error)}`);
289+
return false;
290+
}
291+
}
292+
234293
async function finalizeCliContextEngineTurn(params: {
235294
context: PreparedCliRunContext;
236295
historyMessages: unknown[];
@@ -594,11 +653,16 @@ export async function runPreparedCliAgent(
594653
output: Awaited<ReturnType<typeof executePreparedCliRun>>;
595654
effectiveCliSessionId?: string;
596655
bindingFlushOk?: boolean;
656+
assistantTranscriptOwned?: boolean;
597657
}): EmbeddedAgentRunResult => {
598658
const text = resultParams.output.text?.trim();
599659
const rawText = resultParams.output.rawText?.trim();
600660
const payloads = text
601-
? [{ text }]
661+
? [
662+
resultParams.assistantTranscriptOwned
663+
? setReplyPayloadMetadata({ text }, { assistantTranscriptOwned: true })
664+
: { text },
665+
]
602666
: params.allowEmptyAssistantReplyAsSilent === true
603667
? [{ text: SILENT_REPLY_TOKEN }]
604668
: undefined;
@@ -718,6 +782,12 @@ export async function runPreparedCliAgent(
718782
assistantText,
719783
output,
720784
});
785+
const assistantTranscriptOwned = await persistCliAssistantTranscript({
786+
runParams: params,
787+
text: assistantText,
788+
modelId: context.modelId,
789+
usage: output.usage,
790+
});
721791
const bindingFlushOk = await isCliBindingFlushed(
722792
effectiveCliSessionId,
723793
params.provider,
@@ -732,7 +802,12 @@ export async function runPreparedCliAgent(
732802
ctx: hookContext,
733803
hookRunner,
734804
});
735-
return buildCliRunResult({ output, effectiveCliSessionId, bindingFlushOk });
805+
return buildCliRunResult({
806+
output,
807+
effectiveCliSessionId,
808+
bindingFlushOk,
809+
assistantTranscriptOwned,
810+
});
736811
};
737812

738813
if (hasBeforeAgentRunHooks && hookRunner) {
@@ -880,6 +955,9 @@ export function buildRunClaudeCliAgentParams(params: RunClaudeCliAgentParams): R
880955
cwd: params.cwd,
881956
config: params.config,
882957
prompt: params.prompt,
958+
persistAssistantTranscript: params.persistAssistantTranscript,
959+
storePath: params.storePath,
960+
currentInboundEventKind: params.currentInboundEventKind,
883961
provider: params.provider ?? "claude-cli",
884962
model: params.model ?? "opus",
885963
thinkLevel: params.thinkLevel,

0 commit comments

Comments
 (0)