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

Commit 75e49f6

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 75e49f6

2 files changed

Lines changed: 183 additions & 14 deletions

File tree

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

Lines changed: 123 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,117 @@ 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+
3449+
it("preserves an initially visible attachment while polling for another", async () => {
3450+
vi.useFakeTimers();
3451+
const internals = buildInternals();
3452+
const firstArtifact = {
3453+
id: "att-1",
3454+
name: "first.txt",
3455+
type: "user_attachment" as const,
3456+
storage_path: "tasks/artifacts/first.txt",
3457+
content_type: "text/plain",
3458+
};
3459+
const secondArtifact = {
3460+
id: "att-2",
3461+
name: "second.txt",
3462+
type: "user_attachment" as const,
3463+
storage_path: "tasks/artifacts/second.txt",
3464+
content_type: "text/plain",
3465+
};
3466+
internals.posthogAPI.getTaskRun = vi
3467+
.fn()
3468+
.mockResolvedValueOnce(
3469+
createTaskRun({
3470+
state: { pending_user_artifact_ids: ["att-1", "att-2"] },
3471+
artifacts: [],
3472+
}),
3473+
)
3474+
.mockResolvedValue(
3475+
createTaskRun({
3476+
state: { pending_user_artifact_ids: ["att-1", "att-2"] },
3477+
artifacts: [secondArtifact],
3478+
}),
3479+
);
3480+
internals.posthogAPI.downloadArtifact = vi.fn(async () =>
3481+
exactArrayBuffer(new TextEncoder().encode("body")),
3482+
);
3483+
3484+
const resultPromise = internals.getPendingUserPrompt(
3485+
createTaskRun({
3486+
state: { pending_user_artifact_ids: ["att-1", "att-2"] },
3487+
artifacts: [firstArtifact],
3488+
}),
3489+
);
3490+
await vi.runAllTimersAsync();
3491+
const result = await resultPromise;
3492+
3493+
expect(
3494+
result?.prompt.filter((block) => block.type === "resource_link"),
3495+
).toHaveLength(2);
3496+
expect(
3497+
result?.prompt.some(
3498+
(block) =>
3499+
block.type === "text" && block.text.includes("could not be loaded"),
3500+
),
3501+
).toBe(false);
3502+
});
3503+
33903504
it("returns null without refetching when no pending artifacts were declared", async () => {
33913505
const internals = buildInternals();
33923506
const getTaskRun = vi.fn();
@@ -3401,6 +3515,7 @@ describe("AgentServer pending user attachments", () => {
34013515
});
34023516

34033517
it("warns once (not twice) about a missing artifact across the speculative and post-refetch resolves", async () => {
3518+
vi.useFakeTimers();
34043519
const internals = buildInternals();
34053520
// A non-empty manifest that never lists the requested id — so getArtifactsById
34063521
// reaches its per-id "missing" warning on both the pre- and post-refetch calls
@@ -3425,12 +3540,14 @@ describe("AgentServer pending user attachments", () => {
34253540
.spyOn(loggerHost.logger, "warn")
34263541
.mockImplementation(() => {});
34273542

3428-
await internals.getPendingUserPrompt(
3543+
const resultPromise = internals.getPendingUserPrompt(
34293544
createTaskRun({
34303545
state: { pending_user_artifact_ids: ["missing-attachment"] },
34313546
artifacts: decoyManifest,
34323547
}),
34333548
);
3549+
await vi.runAllTimersAsync();
3550+
await resultPromise;
34343551

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

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

Lines changed: 60 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,13 @@ 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(
2084+
taskRun,
2085+
artifactIds,
2086+
manifest,
2087+
)) ?? manifest;
2088+
resolvedArtifacts = this.getArtifactsById(manifest, artifactIds);
20802089
}
20812090

20822091
const prompt = await this.buildPromptFromContentAndArtifacts({
@@ -2126,6 +2135,49 @@ export class AgentServer {
21262135
return prompt.prompt.length > 0 ? prompt : null;
21272136
}
21282137

2138+
private async resolvePendingArtifactManifest(
2139+
taskRun: TaskRun,
2140+
artifactIds: string[],
2141+
initialManifest: TaskRunArtifact[],
2142+
): Promise<TaskRunArtifact[] | null> {
2143+
let latestManifest = initialManifest;
2144+
2145+
for (let attempt = 1; attempt <= PENDING_ARTIFACT_MAX_ATTEMPTS; attempt++) {
2146+
const refreshed = await this.refetchRunArtifacts(taskRun);
2147+
if (refreshed) {
2148+
const mergedManifest = [...latestManifest];
2149+
for (const artifact of refreshed) {
2150+
const existingIndex = mergedManifest.findIndex(
2151+
(existing) =>
2152+
(artifact.id && existing.id === artifact.id) ||
2153+
(artifact.storage_path &&
2154+
existing.storage_path === artifact.storage_path),
2155+
);
2156+
if (existingIndex >= 0) {
2157+
mergedManifest[existingIndex] = artifact;
2158+
} else {
2159+
mergedManifest.push(artifact);
2160+
}
2161+
}
2162+
latestManifest = mergedManifest;
2163+
const resolvedArtifacts = this.getArtifactsById(
2164+
latestManifest,
2165+
artifactIds,
2166+
{ warnOnMissing: false },
2167+
);
2168+
if (resolvedArtifacts.length === artifactIds.length) {
2169+
return latestManifest;
2170+
}
2171+
}
2172+
2173+
if (attempt < PENDING_ARTIFACT_MAX_ATTEMPTS) {
2174+
await sleep(PENDING_ARTIFACT_RETRY_DELAY_MS * attempt);
2175+
}
2176+
}
2177+
2178+
return latestManifest.length > 0 ? latestManifest : null;
2179+
}
2180+
21292181
// Best-effort refetch of a run's artifact manifest. Returns null on any error
21302182
// so the caller can fall back to the manifest it already has.
21312183
private async refetchRunArtifacts(

0 commit comments

Comments
 (0)