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

Commit 39b149d

Browse files
authored
fix(cloud-task): deliver first-message skill bundles through prewarmed runs (#3173)
1 parent 2dda392 commit 39b149d

9 files changed

Lines changed: 462 additions & 49 deletions

File tree

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1561,19 +1561,29 @@ export class AgentServer {
15611561
? this.getInitialPromptOverride(taskRun)
15621562
: null;
15631563
const pendingUserPrompt = await this.getPendingUserPrompt(taskRun);
1564+
// A prewarmed run gets its first message forwarded as a user_message
1565+
// signal on activation; building one from task.description here too
1566+
// would deliver it twice (and without the forwarded artifacts).
1567+
const prewarmed = !!(
1568+
taskRun?.state as Record<string, unknown> | undefined
1569+
)?.prewarmed;
15641570
let initialPrompt: ContentBlock[] = [];
15651571
let initialPromptMeta: Record<string, unknown> | undefined;
15661572
if (pendingUserPrompt?.prompt.length) {
15671573
initialPrompt = pendingUserPrompt.prompt;
15681574
initialPromptMeta = pendingUserPrompt.meta;
15691575
} else if (initialPromptOverride) {
15701576
initialPrompt = [{ type: "text", text: initialPromptOverride }];
1571-
} else if (task.description) {
1577+
} else if (task.description && !prewarmed) {
15721578
initialPrompt = [{ type: "text", text: task.description }];
15731579
}
15741580

15751581
if (initialPrompt.length === 0) {
1576-
this.logger.debug("Task has no description, skipping initial message");
1582+
this.logger.debug(
1583+
prewarmed
1584+
? "Prewarmed run awaits its forwarded first message, skipping initial message"
1585+
: "Task has no description, skipping initial message",
1586+
);
15771587
return;
15781588
}
15791589

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,38 @@ describe("Question relay", () => {
629629
});
630630
});
631631

632+
it("does not build a description prompt for a prewarmed run awaiting its forwarded message", async () => {
633+
vi.spyOn(server.posthogAPI, "getTask").mockResolvedValue({
634+
id: "test-task-id",
635+
title: "t",
636+
description: "/millie readme this skill",
637+
} as unknown as Task);
638+
vi.spyOn(server.posthogAPI, "getTaskRun").mockResolvedValue({
639+
id: "test-run-id",
640+
task: "test-task-id",
641+
state: { prewarmed: true },
642+
} as unknown as TaskRun);
643+
644+
const promptSpy = vi.fn().mockResolvedValue({ stopReason: "end_turn" });
645+
server.session = {
646+
payload: TEST_PAYLOAD,
647+
acpSessionId: "acp-session",
648+
clientConnection: { prompt: promptSpy },
649+
logWriter: {
650+
flushAll: vi.fn().mockResolvedValue(undefined),
651+
getFullAgentResponse: vi.fn().mockReturnValue(null),
652+
resetTurnMessages: vi.fn(),
653+
appendRawLine: vi.fn(),
654+
flush: vi.fn().mockResolvedValue(undefined),
655+
isRegistered: vi.fn().mockReturnValue(true),
656+
},
657+
};
658+
659+
await server.sendInitialTaskMessage(TEST_PAYLOAD);
660+
661+
expect(promptSpy).not.toHaveBeenCalled();
662+
});
663+
632664
it("does not replay a transient upstream termination before any session activity", async () => {
633665
vi.spyOn(server.posthogAPI, "getTask").mockResolvedValue({
634666
id: "test-task-id",

packages/api-client/src/posthog-client.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2218,6 +2218,8 @@ export class PostHogAPIClient {
22182218
model?: string | null;
22192219
reasoning_effort?: string | null;
22202220
channel?: string | null;
2221+
pending_user_message?: string;
2222+
pending_user_artifact_ids?: string[];
22212223
},
22222224
) {
22232225
const teamId = await this.getTeamId();

packages/core/src/task-detail/taskCreationHost.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,20 @@ export interface ITaskCreationHost {
112112
* too, or a typed `/my-skill` reaches the sandbox with no bundle attached.
113113
*/
114114
resolveLocalSkillCommandPrompt(prompt: string): Promise<string>;
115+
/**
116+
* Return-and-clear the pre-warmed sandbox lease matching the composer
117+
* selection, if one was provisioned while the user typed. The saga uploads
118+
* first-message attachments (skill bundles, files) to this run before
119+
* createTask so the backend's warm activation can forward them; null means
120+
* no warm run is known client-side.
121+
*/
122+
takeWarmTaskLease(args: {
123+
repository: string;
124+
branch?: string | null;
125+
runtimeAdapter?: string | null;
126+
model?: string | null;
127+
reasoningEffort?: string | null;
128+
}): { taskId: string; runId: string } | null;
115129
uploadRunAttachments(
116130
client: TaskCreationApiClient,
117131
taskId: string,

packages/core/src/task-detail/taskCreationSaga.test.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ const mockHost = vi.hoisted(() => ({
2121
detectRepo: vi.fn(),
2222
getCloudPromptTransport: vi.fn(),
2323
resolveLocalSkillCommandPrompt: vi.fn(async (prompt: string) => prompt),
24+
takeWarmTaskLease: vi.fn(
25+
(): { taskId: string; runId: string } | null => null,
26+
),
2427
uploadRunAttachments: vi.fn(),
2528
setProvisioningActive: vi.fn(),
2629
clearProvisioning: vi.fn(),
@@ -516,6 +519,167 @@ describe("TaskCreationSaga", () => {
516519
},
517520
);
518521

522+
it("uploads skill bundles to the warm run and passes pending fields through createTask", async () => {
523+
const skillTag =
524+
'<skill name="my-skill" source="user" path="/skills/my-skill" /> do it';
525+
mockHost.resolveLocalSkillCommandPrompt.mockResolvedValue(skillTag);
526+
mockHost.getCloudPromptTransport.mockReturnValue({
527+
filePaths: [],
528+
skillBundles: [
529+
{ name: "my-skill", source: "user", path: "/skills/my-skill" },
530+
],
531+
messageText: "/my-skill do it",
532+
promptText: "/my-skill do it",
533+
});
534+
mockHost.takeWarmTaskLease.mockReturnValue({
535+
taskId: "warm-task",
536+
runId: "warm-run",
537+
});
538+
mockHost.uploadRunAttachments.mockResolvedValue(["skill-artifact-1"]);
539+
540+
const warmActivatedTask = createTask({
541+
id: "warm-task",
542+
latest_run: createRun({ id: "warm-run", task: "warm-task" }),
543+
});
544+
const createTaskMock = vi.fn().mockResolvedValue(warmActivatedTask);
545+
const createTaskRunMock = vi.fn();
546+
const startTaskRunMock = vi.fn();
547+
const saga = makeSaga({
548+
createTask: createTaskMock,
549+
createTaskRun: createTaskRunMock,
550+
startTaskRun: startTaskRunMock,
551+
});
552+
553+
const result = await saga.run({
554+
content: "/my-skill do it",
555+
repository: "posthog/posthog",
556+
workspaceMode: "cloud",
557+
branch: "main",
558+
});
559+
560+
expect(result.success).toBe(true);
561+
expect(mockHost.takeWarmTaskLease).toHaveBeenCalledWith({
562+
repository: "posthog/posthog",
563+
branch: "main",
564+
runtimeAdapter: null,
565+
model: null,
566+
reasoningEffort: null,
567+
});
568+
// The bundle must land on the warm run before createTask triggers activation.
569+
expect(mockHost.uploadRunAttachments).toHaveBeenCalledWith(
570+
expect.anything(),
571+
"warm-task",
572+
"warm-run",
573+
[],
574+
[{ name: "my-skill", source: "user", path: "/skills/my-skill" }],
575+
);
576+
expect(createTaskMock).toHaveBeenCalledWith(
577+
expect.objectContaining({
578+
branch: "main",
579+
pending_user_message: "/my-skill do it",
580+
pending_user_artifact_ids: ["skill-artifact-1"],
581+
}),
582+
);
583+
// Warm-activated at create time: no fresh run is created or started.
584+
expect(createTaskRunMock).not.toHaveBeenCalled();
585+
expect(startTaskRunMock).not.toHaveBeenCalled();
586+
});
587+
588+
it("suppresses warm reuse when attachments exist but no warm lease is known", async () => {
589+
const skillTag =
590+
'<skill name="my-skill" source="user" path="/skills/my-skill" /> do it';
591+
mockHost.resolveLocalSkillCommandPrompt.mockResolvedValue(skillTag);
592+
mockHost.getCloudPromptTransport.mockReturnValue({
593+
filePaths: [],
594+
skillBundles: [
595+
{ name: "my-skill", source: "user", path: "/skills/my-skill" },
596+
],
597+
messageText: "/my-skill do it",
598+
promptText: "/my-skill do it",
599+
});
600+
mockHost.takeWarmTaskLease.mockReturnValue(null);
601+
mockHost.uploadRunAttachments.mockResolvedValue(["skill-artifact-1"]);
602+
603+
const createdTask = createTask();
604+
const startedTask = createTask({ latest_run: createRun() });
605+
const createTaskMock = vi.fn().mockResolvedValue(createdTask);
606+
const createTaskRunMock = vi.fn().mockResolvedValue(createRun());
607+
const startTaskRunMock = vi.fn().mockResolvedValue(startedTask);
608+
const saga = makeSaga({
609+
createTask: createTaskMock,
610+
createTaskRun: createTaskRunMock,
611+
startTaskRun: startTaskRunMock,
612+
});
613+
614+
const result = await saga.run({
615+
content: "/my-skill do it",
616+
repository: "posthog/posthog",
617+
workspaceMode: "cloud",
618+
branch: "main",
619+
});
620+
621+
expect(result.success).toBe(true);
622+
// No lease to upload to: omit the warm-reuse branch hint so the backend
623+
// cannot activate a warm run this client can't attach the bundle to.
624+
expect(createTaskMock.mock.calls[0][0].branch).toBeUndefined();
625+
// Cold path proceeds and delivers the bundle through the run start.
626+
expect(startTaskRunMock).toHaveBeenCalledWith("task-123", "run-123", {
627+
pendingUserMessage: "/my-skill do it",
628+
pendingUserArtifactIds: ["skill-artifact-1"],
629+
});
630+
});
631+
632+
it("falls back to cold creation when the warm-run upload fails", async () => {
633+
const skillTag =
634+
'<skill name="my-skill" source="user" path="/skills/my-skill" /> do it';
635+
mockHost.resolveLocalSkillCommandPrompt.mockResolvedValue(skillTag);
636+
mockHost.getCloudPromptTransport.mockReturnValue({
637+
filePaths: [],
638+
skillBundles: [
639+
{ name: "my-skill", source: "user", path: "/skills/my-skill" },
640+
],
641+
messageText: "/my-skill do it",
642+
promptText: "/my-skill do it",
643+
});
644+
mockHost.takeWarmTaskLease.mockReturnValue({
645+
taskId: "warm-task",
646+
runId: "warm-run",
647+
});
648+
mockHost.uploadRunAttachments
649+
.mockRejectedValueOnce(new Error("warm upload failed"))
650+
.mockResolvedValueOnce(["skill-artifact-1"]);
651+
652+
const createdTask = createTask();
653+
const startedTask = createTask({ latest_run: createRun() });
654+
const createTaskMock = vi.fn().mockResolvedValue(createdTask);
655+
const createTaskRunMock = vi.fn().mockResolvedValue(createRun());
656+
const startTaskRunMock = vi.fn().mockResolvedValue(startedTask);
657+
const saga = makeSaga({
658+
createTask: createTaskMock,
659+
createTaskRun: createTaskRunMock,
660+
startTaskRun: startTaskRunMock,
661+
});
662+
663+
const result = await saga.run({
664+
content: "/my-skill do it",
665+
repository: "posthog/posthog",
666+
workspaceMode: "cloud",
667+
branch: "main",
668+
});
669+
670+
// The failed pre-upload must not fail creation or activate warm without
671+
// the bundle: warm reuse is suppressed and the cold path re-uploads.
672+
expect(result.success).toBe(true);
673+
expect(createTaskMock.mock.calls[0][0].branch).toBeUndefined();
674+
expect(
675+
createTaskMock.mock.calls[0][0].pending_user_artifact_ids,
676+
).toBeUndefined();
677+
expect(startTaskRunMock).toHaveBeenCalledWith("task-123", "run-123", {
678+
pendingUserMessage: "/my-skill do it",
679+
pendingUserArtifactIds: ["skill-artifact-1"],
680+
});
681+
});
682+
519683
it("uses the selected user GitHub integration for cloud task creation", async () => {
520684
const createdTask = createTask({
521685
github_user_integration: "user-integration-123",

0 commit comments

Comments
 (0)