Skip to content

Commit 58d7086

Browse files
authored
fix(agent): harden compact continuation lifecycle
Suppress intermediate adapter turn-complete notifications until the compact continuation finishes. Track pending continuations by message ID so redelivery retries only the continuation after a failure instead of compacting again. Generated-By: PostHog Code Task-Id: 233a983e-daa3-4d81-af56-bb43488687ab
1 parent f8b1c57 commit 58d7086

2 files changed

Lines changed: 151 additions & 41 deletions

File tree

packages/agent/src/server/agent-server.test.ts

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
it,
2626
vi,
2727
} from "vitest";
28+
import { POSTHOG_NOTIFICATIONS } from "../acp-extensions";
2829
import { getSessionJsonlPath } from "../adapters/claude/session/jsonl-hydration";
2930
import type { PermissionMode } from "../execution-mode";
3031
import type { PostHogAPIClient } from "../posthog-api";
@@ -1436,13 +1437,23 @@ describe("AgentServer HTTP Mode", () => {
14361437
it("continues a cloud task after a manual compact command", async () => {
14371438
const s = createServer();
14381439
await s.start();
1439-
const prompt = vi.fn(async (_params: { prompt: ContentBlock[] }) => ({
1440-
stopReason: "end_turn",
1441-
}));
1442-
const serverInternals = s as unknown as {
1440+
const broadcastEvent = vi.fn();
1441+
let serverInternals!: {
14431442
session: { clientConnection: { prompt: typeof prompt } };
1443+
broadcastEvent: typeof broadcastEvent;
1444+
handleAcpTransportMessage(message: unknown): void;
14441445
};
1446+
const prompt = vi.fn(async (_params: { prompt: ContentBlock[] }) => {
1447+
serverInternals.handleAcpTransportMessage({
1448+
jsonrpc: "2.0",
1449+
method: POSTHOG_NOTIFICATIONS.TURN_COMPLETE,
1450+
params: { sessionId: "session-1", stopReason: "end_turn" },
1451+
});
1452+
return { stopReason: "end_turn" };
1453+
});
1454+
serverInternals = s as unknown as typeof serverInternals;
14451455
serverInternals.session.clientConnection.prompt = prompt;
1456+
serverInternals.broadcastEvent = broadcastEvent;
14461457

14471458
const token = createToken();
14481459
const response = await fetch(`http://localhost:${port}/command`, {
@@ -1481,6 +1492,66 @@ describe("AgentServer HTTP Mode", () => {
14811492
_meta: { ui: { hidden: true } },
14821493
},
14831494
]);
1495+
const turnCompleteEvents = broadcastEvent.mock.calls.filter(
1496+
([event]) =>
1497+
(event as { notification?: { method?: string } }).notification
1498+
?.method === POSTHOG_NOTIFICATIONS.TURN_COMPLETE,
1499+
);
1500+
expect(turnCompleteEvents).toHaveLength(1);
1501+
}, 20000);
1502+
1503+
it("retries only the continuation after compact follow-up failure", async () => {
1504+
const s = createServer();
1505+
await s.start();
1506+
const prompt = vi
1507+
.fn(async (_params: { prompt: ContentBlock[] }) => ({
1508+
stopReason: "end_turn",
1509+
}))
1510+
.mockResolvedValueOnce({ stopReason: "end_turn" })
1511+
.mockRejectedValueOnce(new Error("sdk connection lost"));
1512+
const serverInternals = s as unknown as {
1513+
session: { clientConnection: { prompt: typeof prompt } };
1514+
};
1515+
serverInternals.session.clientConnection.prompt = prompt;
1516+
1517+
const token = createToken();
1518+
const send = async () =>
1519+
fetch(`http://localhost:${port}/command`, {
1520+
method: "POST",
1521+
headers: {
1522+
Authorization: `Bearer ${token}`,
1523+
"Content-Type": "application/json",
1524+
},
1525+
body: JSON.stringify({
1526+
jsonrpc: "2.0",
1527+
id: "compact-retry",
1528+
method: "user_message",
1529+
params: {
1530+
content: "/compact Continue the task.",
1531+
messageId: "compact-retry",
1532+
},
1533+
}),
1534+
});
1535+
1536+
const first = await send();
1537+
expect(first.status).toBe(200);
1538+
expect(prompt).toHaveBeenCalledTimes(2);
1539+
1540+
const retry = await send();
1541+
expect(retry.status).toBe(200);
1542+
expect(prompt).toHaveBeenCalledTimes(3);
1543+
expect(prompt.mock.calls[0]?.[0].prompt[0]).toMatchObject({
1544+
type: "text",
1545+
text: "/compact Continue the task.",
1546+
});
1547+
expect(prompt.mock.calls[1]?.[0].prompt[0]).toMatchObject({
1548+
type: "text",
1549+
text: expect.stringContaining("Continue working on the task"),
1550+
});
1551+
expect(prompt.mock.calls[2]?.[0].prompt[0]).toMatchObject({
1552+
type: "text",
1553+
text: expect.stringContaining("Continue working on the task"),
1554+
});
14841555
}, 20000);
14851556

14861557
it("rewrites a bundled local skill slash command before sending the prompt", async () => {

packages/agent/src/server/agent-server.ts

Lines changed: 76 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,7 @@ export class AgentServer {
343343
private rtkSavingsAttempted = false;
344344
private questionRelayedToSlack = false;
345345
private adapterEmittedTurnComplete = false;
346+
private suppressAdapterTurnComplete = false;
346347
private runUsage = new RunUsageAccumulator();
347348
private detectedPrUrl: string | null = null;
348349
// Reset per session. `evaluatedPrUrls` dedupes per URL; `prAttributionChain` serializes
@@ -368,6 +369,7 @@ export class AgentServer {
368369
private initializationPromise: Promise<void> | null = null;
369370
private pendingEvents: Record<string, unknown>[] = [];
370371
private deliveredMessageIds = new Set<string>();
372+
private pendingCompactContinuationMessageIds = new Set<string>();
371373
private pendingPermissions = new Map<
372374
string,
373375
{
@@ -913,18 +915,28 @@ export class AgentServer {
913915
typeof params.messageId === "string" && params.messageId
914916
? params.messageId
915917
: undefined;
918+
let retryCompactContinuation = false;
916919
if (messageId) {
917920
if (this.deliveredMessageIds.has(messageId)) {
918-
this.logger.info("Duplicate user_message delivery ignored", {
919-
messageId,
920-
});
921-
return { stopReason: "duplicate_delivery", duplicate: true };
921+
if (this.pendingCompactContinuationMessageIds.has(messageId)) {
922+
retryCompactContinuation = true;
923+
this.logger.info("Retrying pending compact continuation", {
924+
messageId,
925+
});
926+
} else {
927+
this.logger.info("Duplicate user_message delivery ignored", {
928+
messageId,
929+
});
930+
return { stopReason: "duplicate_delivery", duplicate: true };
931+
}
932+
} else {
933+
this.deliveredMessageIds.add(messageId);
922934
}
923-
this.deliveredMessageIds.add(messageId);
924935
if (this.deliveredMessageIds.size > 500) {
925936
const oldest = this.deliveredMessageIds.values().next().value;
926937
if (oldest !== undefined) {
927938
this.deliveredMessageIds.delete(oldest);
939+
this.pendingCompactContinuationMessageIds.delete(oldest);
928940
}
929941
}
930942
}
@@ -955,34 +967,53 @@ export class AgentServer {
955967
: {}),
956968
};
957969

958-
let result: PromptResponse;
959-
try {
960-
result = await this.session.clientConnection.prompt({
961-
sessionId: this.session.acpSessionId,
962-
prompt,
963-
...(Object.keys(promptMeta).length > 0
964-
? { _meta: promptMeta }
965-
: {}),
970+
const manualCompactPrompt = isManualCompactPrompt(prompt);
971+
const acpSessionId = this.session.acpSessionId;
972+
const continueAfterCompaction = (): Promise<PromptResponse> =>
973+
this.promptWithUpstreamRetry({
974+
sessionId: acpSessionId,
975+
prompt: [
976+
hiddenTextBlock(
977+
"Compaction is complete. Continue working on the task from the compacted context, following the user's instructions from the /compact command.",
978+
),
979+
],
966980
});
967981

968-
if (
969-
result.stopReason === "end_turn" &&
970-
isManualCompactPrompt(prompt)
971-
) {
972-
// `/compact` is an SDK-local command, so without a follow-up the
973-
// cloud run reports completion before the model resumes the task.
974-
this.recordTurnUsage(result.usage);
975-
result = await this.promptWithUpstreamRetry({
982+
let compactCommandCompleted = retryCompactContinuation;
983+
let result: PromptResponse;
984+
this.suppressAdapterTurnComplete =
985+
manualCompactPrompt || retryCompactContinuation;
986+
try {
987+
if (retryCompactContinuation) {
988+
result = await continueAfterCompaction();
989+
if (messageId) {
990+
this.pendingCompactContinuationMessageIds.delete(messageId);
991+
}
992+
} else {
993+
result = await this.session.clientConnection.prompt({
976994
sessionId: this.session.acpSessionId,
977-
prompt: [
978-
hiddenTextBlock(
979-
"Compaction is complete. Continue working on the task from the compacted context, following the user's instructions from the /compact command.",
980-
),
981-
],
995+
prompt,
996+
...(Object.keys(promptMeta).length > 0
997+
? { _meta: promptMeta }
998+
: {}),
982999
});
1000+
1001+
if (result.stopReason === "end_turn" && manualCompactPrompt) {
1002+
compactCommandCompleted = true;
1003+
if (messageId) {
1004+
this.pendingCompactContinuationMessageIds.add(messageId);
1005+
}
1006+
// `/compact` is an SDK-local command, so without a follow-up the
1007+
// cloud run reports completion before the model resumes the task.
1008+
this.recordTurnUsage(result.usage);
1009+
result = await continueAfterCompaction();
1010+
if (messageId) {
1011+
this.pendingCompactContinuationMessageIds.delete(messageId);
1012+
}
1013+
}
9831014
}
9841015
} catch (error) {
985-
if (messageId) {
1016+
if (messageId && !compactCommandCompleted) {
9861017
this.deliveredMessageIds.delete(messageId);
9871018
}
9881019
await this.session.logWriter.flushAll();
@@ -995,6 +1026,8 @@ export class AgentServer {
9951026
throw error;
9961027
}
9971028
return { stopReason: "error_recoverable" };
1029+
} finally {
1030+
this.suppressAdapterTurnComplete = false;
9981031
}
9991032

10001033
this.logger.debug("User message completed", {
@@ -1320,16 +1353,8 @@ export class AgentServer {
13201353

13211354
// Tap both streams to broadcast all ACP messages via SSE (mimics local transport)
13221355
this.adapterEmittedTurnComplete = false;
1323-
const onAcpMessage = (message: unknown) => {
1324-
if (isTurnCompleteNotification(message)) {
1325-
this.adapterEmittedTurnComplete = true;
1326-
}
1327-
this.broadcastEvent({
1328-
type: "notification",
1329-
timestamp: new Date().toISOString(),
1330-
notification: message,
1331-
});
1332-
};
1356+
const onAcpMessage = (message: unknown) =>
1357+
this.handleAcpTransportMessage(message);
13331358

13341359
const tappedReadable = createTappedReadableStream(
13351360
acpConnection.clientStreams.readable as ReadableStream<Uint8Array>,
@@ -4066,6 +4091,20 @@ ${signedCommitInstructions}${prLinkInstructions}${shellEfficiencyInstructions}
40664091
});
40674092
}
40684093

4094+
private handleAcpTransportMessage(message: unknown): void {
4095+
if (isTurnCompleteNotification(message)) {
4096+
if (this.suppressAdapterTurnComplete) {
4097+
return;
4098+
}
4099+
this.adapterEmittedTurnComplete = true;
4100+
}
4101+
this.broadcastEvent({
4102+
type: "notification",
4103+
timestamp: new Date().toISOString(),
4104+
notification: message,
4105+
});
4106+
}
4107+
40694108
private broadcastTurnComplete(stopReason: string): void {
40704109
if (!this.session) return;
40714110
if (this.adapterEmittedTurnComplete) {

0 commit comments

Comments
 (0)