Skip to content

Commit ddbce59

Browse files
authored
fix(cloud-task): recover queued messages after sandbox stops (#3046)
1 parent 88887ab commit ddbce59

9 files changed

Lines changed: 425 additions & 28 deletions

File tree

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

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1372,6 +1372,109 @@ describe("AgentServer HTTP Mode", () => {
13721372
});
13731373
});
13741374

1375+
describe("resume prompt display", () => {
1376+
it("hides synthetic resume context while keeping the pending user message visible", async () => {
1377+
const s = createServer() as unknown as {
1378+
resumeState: ResumeState | null;
1379+
session: {
1380+
payload: JwtPayload;
1381+
acpSessionId: string;
1382+
clientConnection: {
1383+
prompt: ReturnType<typeof vi.fn>;
1384+
};
1385+
logWriter: {
1386+
resetTurnMessages: ReturnType<typeof vi.fn>;
1387+
appendRawLine: ReturnType<typeof vi.fn>;
1388+
flushAll: ReturnType<typeof vi.fn>;
1389+
};
1390+
sseController: null;
1391+
deviceInfo: { type: "cloud"; name: string };
1392+
permissionMode: PermissionMode;
1393+
hasDesktopConnected: boolean;
1394+
};
1395+
sendResumeMessage(
1396+
payload: JwtPayload,
1397+
taskRun: TaskRun | null,
1398+
): Promise<void>;
1399+
};
1400+
const payload: JwtPayload = {
1401+
run_id: "test-run-id",
1402+
task_id: "test-task-id",
1403+
team_id: 1,
1404+
user_id: 1,
1405+
distinct_id: "test-distinct-id",
1406+
mode: "interactive",
1407+
};
1408+
const prompt = vi.fn(async () => ({ stopReason: "cancelled" }));
1409+
s.session = {
1410+
payload,
1411+
acpSessionId: "acp-session",
1412+
clientConnection: { prompt },
1413+
logWriter: {
1414+
resetTurnMessages: vi.fn(),
1415+
appendRawLine: vi.fn(),
1416+
flushAll: vi.fn(),
1417+
},
1418+
sseController: null,
1419+
deviceInfo: { type: "cloud", name: "test-sandbox" },
1420+
permissionMode: "bypassPermissions",
1421+
hasDesktopConnected: false,
1422+
};
1423+
s.resumeState = {
1424+
conversation: [
1425+
{ role: "user", content: [{ type: "text", text: "old request" }] },
1426+
{
1427+
role: "assistant",
1428+
content: [{ type: "text", text: "old answer" }],
1429+
},
1430+
],
1431+
latestGitCheckpoint: null,
1432+
interrupted: false,
1433+
logEntryCount: 2,
1434+
sessionId: "prior-session",
1435+
};
1436+
1437+
await s.sendResumeMessage(
1438+
payload,
1439+
createTaskRun({
1440+
id: "test-run-id",
1441+
task: "test-task-id",
1442+
state: {
1443+
pending_user_message: "visible follow-up",
1444+
pending_user_message_ts: "123.456",
1445+
},
1446+
}),
1447+
);
1448+
1449+
const [{ prompt: promptBlocks }] = prompt.mock.calls[0] as unknown as [
1450+
{ prompt: ContentBlock[] },
1451+
];
1452+
const visibleText = promptBlocks
1453+
.filter(
1454+
(block) =>
1455+
block.type === "text" &&
1456+
!(
1457+
(block as { _meta?: { ui?: { hidden?: boolean } } })._meta?.ui
1458+
?.hidden === true
1459+
),
1460+
)
1461+
.map((block) => (block as { text: string }).text);
1462+
1463+
expect(promptBlocks[0]).toMatchObject({
1464+
type: "text",
1465+
_meta: { ui: { hidden: true } },
1466+
});
1467+
expect((promptBlocks[0] as { text: string }).text).toContain(
1468+
"You are resuming a previous conversation",
1469+
);
1470+
expect(visibleText).toEqual(["visible follow-up"]);
1471+
expect(promptBlocks.at(-1)).toMatchObject({
1472+
type: "text",
1473+
_meta: { ui: { hidden: true } },
1474+
});
1475+
});
1476+
});
1477+
13751478
describe("runtime adapter selection", () => {
13761479
it("defaults to claude when no runtime adapter is configured", () => {
13771480
const s = createServer();

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

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,14 @@ interface BuiltPrompt {
250250
meta?: Record<string, unknown>;
251251
}
252252

253+
function hiddenTextBlock(text: string): ContentBlock {
254+
return {
255+
type: "text",
256+
text,
257+
_meta: { ui: { hidden: true } },
258+
} as ContentBlock;
259+
}
260+
253261
interface LocalSkillPromptContext {
254262
skillName: string;
255263
context: string;
@@ -1571,39 +1579,34 @@ export class AgentServer {
15711579

15721580
const pendingUserPrompt = await this.getPendingUserPrompt(taskRun);
15731581

1574-
const sandboxContext = checkpointApplied
1582+
const checkpointContext = checkpointApplied
15751583
? `The workspace environment (all files, packages, and code changes) has been fully restored from the latest checkpoint.`
1576-
: `The workspace from the previous session was not restored from a checkpoint, so you are starting with a fresh environment. Your conversation history is fully preserved below.`;
1584+
: `No additional git checkpoint was applied before resuming. Use the current workspace contents together with the preserved conversation history below.`;
15771585

15781586
let resumePromptBlocks: ContentBlock[];
15791587
let resumePromptMeta: Record<string, unknown> | undefined;
15801588
if (pendingUserPrompt?.prompt.length) {
15811589
resumePromptMeta = pendingUserPrompt.meta;
15821590
resumePromptBlocks = [
1583-
{
1584-
type: "text",
1585-
text:
1586-
`You are resuming a previous conversation. ${sandboxContext}\n\n` +
1591+
hiddenTextBlock(
1592+
`You are resuming a previous conversation. ${checkpointContext}\n\n` +
15871593
`Here is the conversation history from the previous session:\n\n` +
15881594
`${conversationSummary}\n\n` +
15891595
`The user has sent a new message:\n\n`,
1590-
},
1596+
),
15911597
...pendingUserPrompt.prompt,
1592-
{
1593-
type: "text",
1594-
text: "\n\nRespond to the user's new message above. You have full context from the previous session.",
1595-
},
1598+
hiddenTextBlock(
1599+
"\n\nRespond to the user's new message above. You have full context from the previous session.",
1600+
),
15961601
];
15971602
} else {
15981603
resumePromptBlocks = [
1599-
{
1600-
type: "text",
1601-
text:
1602-
`You are resuming a previous conversation. ${sandboxContext}\n\n` +
1604+
hiddenTextBlock(
1605+
`You are resuming a previous conversation. ${checkpointContext}\n\n` +
16031606
`Here is the conversation history from the previous session:\n\n` +
16041607
`${conversationSummary}\n\n` +
16051608
`Continue from where you left off. The user is waiting for your response.`,
1606-
},
1609+
),
16071610
];
16081611
}
16091612

packages/core/src/cloud-task/cloud-task-types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export interface CloudTaskStatusUpdate extends CloudTaskUpdateBase {
1818
output?: Record<string, unknown> | null;
1919
errorMessage?: string | null;
2020
branch?: string | null;
21+
sandboxAlive?: boolean | null;
2122
}
2223

2324
export interface CloudTaskSnapshotUpdate extends CloudTaskUpdateBase {
@@ -29,6 +30,7 @@ export interface CloudTaskSnapshotUpdate extends CloudTaskUpdateBase {
2930
output?: Record<string, unknown> | null;
3031
errorMessage?: string | null;
3132
branch?: string | null;
33+
sandboxAlive?: boolean | null;
3234
}
3335

3436
export interface CloudTaskErrorUpdate extends CloudTaskUpdateBase {

packages/core/src/cloud-task/cloud-task.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,75 @@ describe("CloudTaskService", () => {
340340
);
341341
});
342342

343+
it("emits sandbox liveness from run detail when retrying a live watcher", async () => {
344+
const updates: unknown[] = [];
345+
service.on(CloudTaskEvent.Update, (payload) => updates.push(payload));
346+
347+
mockNetFetch
348+
.mockResolvedValueOnce(
349+
createJsonResponse({
350+
id: "run-1",
351+
status: "in_progress",
352+
stage: null,
353+
output: null,
354+
state: { sandbox_alive: true },
355+
error_message: null,
356+
branch: "main",
357+
updated_at: "2026-01-01T00:00:00Z",
358+
}),
359+
)
360+
.mockResolvedValueOnce(
361+
createJsonResponse([], 200, { "X-Has-More": "false" }),
362+
)
363+
.mockResolvedValueOnce(
364+
createJsonResponse({
365+
id: "run-1",
366+
status: "in_progress",
367+
stage: null,
368+
output: null,
369+
state: { sandbox_alive: false },
370+
error_message: null,
371+
branch: "main",
372+
updated_at: "2026-01-01T00:01:00Z",
373+
}),
374+
);
375+
376+
mockStreamFetch
377+
.mockResolvedValueOnce(createOpenSseResponse(""))
378+
.mockResolvedValueOnce(createOpenSseResponse(""));
379+
380+
service.watch({
381+
taskId: "task-1",
382+
runId: "run-1",
383+
apiHost: "https://app.example.com",
384+
teamId: 2,
385+
});
386+
387+
await waitFor(() =>
388+
updates.some(
389+
(update) =>
390+
typeof update === "object" &&
391+
update !== null &&
392+
(update as { kind?: string; sandboxAlive?: boolean }).kind ===
393+
"snapshot" &&
394+
(update as { sandboxAlive?: boolean }).sandboxAlive === true,
395+
),
396+
);
397+
398+
await service.retry("task-1", "run-1");
399+
400+
await waitFor(() =>
401+
updates.some(
402+
(update) =>
403+
typeof update === "object" &&
404+
update !== null &&
405+
(update as { kind?: string; sandboxAlive?: boolean }).kind ===
406+
"status" &&
407+
(update as { sandboxAlive?: boolean }).sandboxAlive === false,
408+
),
409+
);
410+
});
411+
343412
it("replays a current snapshot when a subscriber attaches to an existing watcher", async () => {
344413
const updates: unknown[] = [];
345414
service.on(CloudTaskEvent.Update, (payload) => updates.push(payload));

0 commit comments

Comments
 (0)