Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit dc71243

Browse files
authored
fix(agent): continue cloud tasks after compaction (#3463)
1 parent c7c1fec commit dc71243

2 files changed

Lines changed: 204 additions & 23 deletions

File tree

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

Lines changed: 121 additions & 0 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";
@@ -1433,6 +1434,126 @@ describe("AgentServer HTTP Mode", () => {
14331434
expect(body.error).toBe("No active session for this run");
14341435
}, 20000);
14351436

1437+
it("continues a cloud task after a manual compact command", async () => {
1438+
const s = createServer();
1439+
await s.start();
1440+
const broadcastEvent = vi.fn();
1441+
let serverInternals!: {
1442+
session: { clientConnection: { prompt: typeof prompt } };
1443+
broadcastEvent: typeof broadcastEvent;
1444+
handleAcpTransportMessage(message: unknown): void;
1445+
};
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;
1455+
serverInternals.session.clientConnection.prompt = prompt;
1456+
serverInternals.broadcastEvent = broadcastEvent;
1457+
1458+
const token = createToken();
1459+
const response = await fetch(`http://localhost:${port}/command`, {
1460+
method: "POST",
1461+
headers: {
1462+
Authorization: `Bearer ${token}`,
1463+
"Content-Type": "application/json",
1464+
},
1465+
body: JSON.stringify({
1466+
jsonrpc: "2.0",
1467+
id: "compact-and-continue",
1468+
method: "user_message",
1469+
params: {
1470+
content:
1471+
"/compact Continue with the task using the question tool and plan.",
1472+
},
1473+
}),
1474+
});
1475+
1476+
expect(response.status).toBe(200);
1477+
const body = (await response.json()) as {
1478+
result?: { stopReason?: string };
1479+
};
1480+
expect(body.result?.stopReason).toBe("end_turn");
1481+
expect(prompt).toHaveBeenCalledTimes(2);
1482+
expect(prompt.mock.calls[0]?.[0].prompt).toEqual([
1483+
{
1484+
type: "text",
1485+
text: "/compact Continue with the task using the question tool and plan.",
1486+
},
1487+
]);
1488+
expect(prompt.mock.calls[1]?.[0].prompt).toEqual([
1489+
{
1490+
type: "text",
1491+
text: expect.stringContaining("Continue working on the task"),
1492+
_meta: { ui: { hidden: true } },
1493+
},
1494+
]);
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+
});
1555+
}, 20000);
1556+
14361557
it("rewrites a bundled local skill slash command before sending the prompt", async () => {
14371558
const skillDefinition = [
14381559
"---",

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

Lines changed: 83 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,10 @@ function hiddenTextBlock(text: string): ContentBlock {
289289
} as ContentBlock;
290290
}
291291

292+
function isManualCompactPrompt(prompt: ContentBlock[]): boolean {
293+
return /^\/compact(?:\s|$)/.test(promptBlocksToText(prompt).trimStart());
294+
}
295+
292296
interface LocalSkillPromptContext {
293297
/** Set when the message is a bare `/skill` invocation the adapter should strip. */
294298
skillName?: string;
@@ -339,6 +343,7 @@ export class AgentServer {
339343
private rtkSavingsAttempted = false;
340344
private questionRelayedToSlack = false;
341345
private adapterEmittedTurnComplete = false;
346+
private suppressAdapterTurnComplete = false;
342347
private runUsage = new RunUsageAccumulator();
343348
private detectedPrUrl: string | null = null;
344349
// Reset per session. `evaluatedPrUrls` dedupes per URL; `prAttributionChain` serializes
@@ -364,6 +369,7 @@ export class AgentServer {
364369
private initializationPromise: Promise<void> | null = null;
365370
private pendingEvents: Record<string, unknown>[] = [];
366371
private deliveredMessageIds = new Set<string>();
372+
private pendingCompactContinuationMessageIds = new Set<string>();
367373
private pendingPermissions = new Map<
368374
string,
369375
{
@@ -909,18 +915,28 @@ export class AgentServer {
909915
typeof params.messageId === "string" && params.messageId
910916
? params.messageId
911917
: undefined;
918+
let retryCompactContinuation = false;
912919
if (messageId) {
913920
if (this.deliveredMessageIds.has(messageId)) {
914-
this.logger.info("Duplicate user_message delivery ignored", {
915-
messageId,
916-
});
917-
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);
918934
}
919-
this.deliveredMessageIds.add(messageId);
920935
if (this.deliveredMessageIds.size > 500) {
921936
const oldest = this.deliveredMessageIds.values().next().value;
922937
if (oldest !== undefined) {
923938
this.deliveredMessageIds.delete(oldest);
939+
this.pendingCompactContinuationMessageIds.delete(oldest);
924940
}
925941
}
926942
}
@@ -951,17 +967,53 @@ export class AgentServer {
951967
: {}),
952968
};
953969

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+
],
980+
});
981+
982+
let compactCommandCompleted = retryCompactContinuation;
954983
let result: PromptResponse;
984+
this.suppressAdapterTurnComplete =
985+
manualCompactPrompt || retryCompactContinuation;
955986
try {
956-
result = await this.session.clientConnection.prompt({
957-
sessionId: this.session.acpSessionId,
958-
prompt,
959-
...(Object.keys(promptMeta).length > 0
960-
? { _meta: promptMeta }
961-
: {}),
962-
});
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({
994+
sessionId: this.session.acpSessionId,
995+
prompt,
996+
...(Object.keys(promptMeta).length > 0
997+
? { _meta: promptMeta }
998+
: {}),
999+
});
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+
}
1014+
}
9631015
} catch (error) {
964-
if (messageId) {
1016+
if (messageId && !compactCommandCompleted) {
9651017
this.deliveredMessageIds.delete(messageId);
9661018
}
9671019
await this.session.logWriter.flushAll();
@@ -974,6 +1026,8 @@ export class AgentServer {
9741026
throw error;
9751027
}
9761028
return { stopReason: "error_recoverable" };
1029+
} finally {
1030+
this.suppressAdapterTurnComplete = false;
9771031
}
9781032

9791033
this.logger.debug("User message completed", {
@@ -1299,16 +1353,8 @@ export class AgentServer {
12991353

13001354
// Tap both streams to broadcast all ACP messages via SSE (mimics local transport)
13011355
this.adapterEmittedTurnComplete = false;
1302-
const onAcpMessage = (message: unknown) => {
1303-
if (isTurnCompleteNotification(message)) {
1304-
this.adapterEmittedTurnComplete = true;
1305-
}
1306-
this.broadcastEvent({
1307-
type: "notification",
1308-
timestamp: new Date().toISOString(),
1309-
notification: message,
1310-
});
1311-
};
1356+
const onAcpMessage = (message: unknown) =>
1357+
this.handleAcpTransportMessage(message);
13121358

13131359
const tappedReadable = createTappedReadableStream(
13141360
acpConnection.clientStreams.readable as ReadableStream<Uint8Array>,
@@ -4045,6 +4091,20 @@ ${signedCommitInstructions}${prLinkInstructions}${shellEfficiencyInstructions}
40454091
});
40464092
}
40474093

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+
40484108
private broadcastTurnComplete(stopReason: string): void {
40494109
if (!this.session) return;
40504110
if (this.adapterEmittedTurnComplete) {

0 commit comments

Comments
 (0)