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

Commit 9399da3

Browse files
authored
fix(agent): wait for first-turn cloud attachments
Poll the task run artifact manifest with bounded backoff before building the first cloud prompt. This prevents a just-uploaded pasted-text attachment from being omitted when the initial manifest read is briefly stale. Generated-By: PostHog Code Task-Id: 855ffa3f-1804-44a4-8ec4-63cd2bc8b018
1 parent 8bc6353 commit 9399da3

2 files changed

Lines changed: 110 additions & 14 deletions

File tree

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

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3292,6 +3292,7 @@ describe("AgentServer pending user attachments", () => {
32923292
});
32933293

32943294
afterEach(async () => {
3295+
vi.useRealTimers();
32953296
await server?.stop();
32963297
server = undefined;
32973298
await rm(tempDir, { recursive: true, force: true });
@@ -3313,6 +3314,7 @@ describe("AgentServer pending user attachments", () => {
33133314
};
33143315

33153316
it("appends an explicit notice when a pending attachment never reaches the manifest", async () => {
3317+
vi.useFakeTimers();
33163318
const internals = buildInternals();
33173319
// Refetch still can't see the attachment (truly absent, not just lagging).
33183320
const getTaskRun = vi.fn(async () =>
@@ -3323,17 +3325,18 @@ describe("AgentServer pending user attachments", () => {
33233325
);
33243326
internals.posthogAPI.getTaskRun = getTaskRun;
33253327

3326-
const result = await internals.getPendingUserPrompt(
3328+
const resultPromise = internals.getPendingUserPrompt(
33273329
createTaskRun({
33283330
state: { pending_user_artifact_ids: ["missing-attachment"] },
33293331
artifacts: [],
33303332
}),
33313333
);
3334+
await vi.runAllTimersAsync();
3335+
const result = await resultPromise;
33323336

3333-
// Refetched once to recover a lagging manifest, then — still missing —
3334-
// surfaced an explicit notice instead of returning null (which would let the
3335-
// caller fall back to the misleading "Attached files: …" description).
3336-
expect(getTaskRun).toHaveBeenCalledTimes(1);
3337+
// Retried to recover a lagging manifest, then surfaced an explicit notice
3338+
// instead of falling back to the misleading attachment summary.
3339+
expect(getTaskRun).toHaveBeenCalledTimes(4);
33373340
expect(result).not.toBeNull();
33383341
expect(result?.prompt).toHaveLength(1);
33393342
const [block] = result?.prompt ?? [];
@@ -3387,6 +3390,62 @@ describe("AgentServer pending user attachments", () => {
33873390
expect(hasNotice).toBe(false);
33883391
});
33893392

3393+
it("recovers a pending attachment that only lands in a later manifest refetch", async () => {
3394+
vi.useFakeTimers();
3395+
const internals = buildInternals();
3396+
internals.posthogAPI.getTaskRun = vi
3397+
.fn()
3398+
.mockResolvedValueOnce(
3399+
createTaskRun({
3400+
state: { pending_user_artifact_ids: ["att-1"] },
3401+
artifacts: [],
3402+
}),
3403+
)
3404+
.mockResolvedValueOnce(
3405+
createTaskRun({
3406+
state: { pending_user_artifact_ids: ["att-1"] },
3407+
artifacts: [],
3408+
}),
3409+
)
3410+
.mockResolvedValue(
3411+
createTaskRun({
3412+
state: { pending_user_artifact_ids: ["att-1"] },
3413+
artifacts: [
3414+
{
3415+
id: "att-1",
3416+
name: "pasted-text.txt",
3417+
type: "user_attachment",
3418+
storage_path: "tasks/artifacts/pasted-text.txt",
3419+
content_type: "text/plain",
3420+
},
3421+
],
3422+
}),
3423+
);
3424+
internals.posthogAPI.downloadArtifact = vi.fn(async () =>
3425+
exactArrayBuffer(new TextEncoder().encode("pasted body")),
3426+
);
3427+
3428+
const resultPromise = internals.getPendingUserPrompt(
3429+
createTaskRun({
3430+
state: { pending_user_artifact_ids: ["att-1"] },
3431+
artifacts: [],
3432+
}),
3433+
);
3434+
await vi.runAllTimersAsync();
3435+
const result = await resultPromise;
3436+
3437+
expect(internals.posthogAPI.getTaskRun).toHaveBeenCalledTimes(3);
3438+
expect(result?.prompt.some((block) => block.type === "resource_link")).toBe(
3439+
true,
3440+
);
3441+
expect(
3442+
result?.prompt.some(
3443+
(block) =>
3444+
block.type === "text" && block.text.includes("could not be loaded"),
3445+
),
3446+
).toBe(false);
3447+
});
3448+
33903449
it("returns null without refetching when no pending artifacts were declared", async () => {
33913450
const internals = buildInternals();
33923451
const getTaskRun = vi.fn();
@@ -3401,6 +3460,7 @@ describe("AgentServer pending user attachments", () => {
34013460
});
34023461

34033462
it("warns once (not twice) about a missing artifact across the speculative and post-refetch resolves", async () => {
3463+
vi.useFakeTimers();
34043464
const internals = buildInternals();
34053465
// A non-empty manifest that never lists the requested id — so getArtifactsById
34063466
// reaches its per-id "missing" warning on both the pre- and post-refetch calls
@@ -3425,12 +3485,14 @@ describe("AgentServer pending user attachments", () => {
34253485
.spyOn(loggerHost.logger, "warn")
34263486
.mockImplementation(() => {});
34273487

3428-
await internals.getPendingUserPrompt(
3488+
const resultPromise = internals.getPendingUserPrompt(
34293489
createTaskRun({
34303490
state: { pending_user_artifact_ids: ["missing-attachment"] },
34313491
artifacts: decoyManifest,
34323492
}),
34333493
);
3494+
await vi.runAllTimersAsync();
3495+
await resultPromise;
34343496

34353497
// The speculative pre-refetch resolve stays quiet (a miss there is expected);
34363498
// only the post-refetch resolve emits the per-id "missing" warning.

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

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,12 @@ export const SSE_KEEPALIVE_INTERVAL_MS = 25_000;
131131
// cut once, without letting a hard upstream outage loop forever.
132132
const MAX_UPSTREAM_TURN_RETRIES = 2;
133133
const UPSTREAM_TURN_RETRY_DELAY_MS = 5_000;
134+
const PENDING_ARTIFACT_MAX_ATTEMPTS = 4;
135+
const PENDING_ARTIFACT_RETRY_DELAY_MS = 500;
136+
137+
function sleep(ms: number): Promise<void> {
138+
return new Promise((resolve) => setTimeout(resolve, ms));
139+
}
134140

135141
class NdJsonTap {
136142
private decoder = new TextDecoder();
@@ -2061,9 +2067,10 @@ export class AgentServer {
20612067

20622068
// The run's artifact manifest can momentarily lag the pending-artifact ids
20632069
// when a run starts right after the attachments were uploaded. If we were
2064-
// asked for artifacts the manifest doesn't list yet, refetch the run once so
2065-
// a transient gap doesn't drop the attachment and send the agent the bare
2066-
// "Attached files: …" description instead of the file it was promised.
2070+
// asked for artifacts the manifest doesn't list yet, poll the run with a
2071+
// short backoff so a transient gap doesn't drop the attachment and send the
2072+
// agent the bare "Attached files: …" description instead of the file it was
2073+
// promised.
20672074
let manifest = taskRun.artifacts ?? [];
20682075
let resolvedArtifacts = this.getArtifactsById(manifest, artifactIds, {
20692076
warnOnMissing: false,
@@ -2072,11 +2079,10 @@ export class AgentServer {
20722079
artifactIds.length > 0 &&
20732080
resolvedArtifacts.length < artifactIds.length
20742081
) {
2075-
const refreshed = await this.refetchRunArtifacts(taskRun);
2076-
if (refreshed) {
2077-
manifest = refreshed;
2078-
resolvedArtifacts = this.getArtifactsById(manifest, artifactIds);
2079-
}
2082+
manifest =
2083+
(await this.resolvePendingArtifactManifest(taskRun, artifactIds)) ??
2084+
manifest;
2085+
resolvedArtifacts = this.getArtifactsById(manifest, artifactIds);
20802086
}
20812087

20822088
const prompt = await this.buildPromptFromContentAndArtifacts({
@@ -2126,6 +2132,34 @@ export class AgentServer {
21262132
return prompt.prompt.length > 0 ? prompt : null;
21272133
}
21282134

2135+
private async resolvePendingArtifactManifest(
2136+
taskRun: TaskRun,
2137+
artifactIds: string[],
2138+
): Promise<TaskRunArtifact[] | null> {
2139+
let latestManifest: TaskRunArtifact[] | null = null;
2140+
2141+
for (let attempt = 1; attempt <= PENDING_ARTIFACT_MAX_ATTEMPTS; attempt++) {
2142+
const refreshed = await this.refetchRunArtifacts(taskRun);
2143+
if (refreshed) {
2144+
latestManifest = refreshed;
2145+
const resolvedArtifacts = this.getArtifactsById(
2146+
refreshed,
2147+
artifactIds,
2148+
{ warnOnMissing: false },
2149+
);
2150+
if (resolvedArtifacts.length === artifactIds.length) {
2151+
return refreshed;
2152+
}
2153+
}
2154+
2155+
if (attempt < PENDING_ARTIFACT_MAX_ATTEMPTS) {
2156+
await sleep(PENDING_ARTIFACT_RETRY_DELAY_MS * attempt);
2157+
}
2158+
}
2159+
2160+
return latestManifest;
2161+
}
2162+
21292163
// Best-effort refetch of a run's artifact manifest. Returns null on any error
21302164
// so the caller can fall back to the manifest it already has.
21312165
private async refetchRunArtifacts(

0 commit comments

Comments
 (0)