Skip to content

Commit 2506621

Browse files
authored
Merge pull request #159 from pylon-code/fix/followup-attachment-paths
fix(server): stop dropping attachments on queued follow-ups
2 parents 97d77e9 + 329e6fd commit 2506621

2 files changed

Lines changed: 85 additions & 22 deletions

File tree

apps/server/src/provider/Layers/ProviderService.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as NodePath from "node:path";
66
import type {
77
ProviderApprovalDecision,
88
ProviderRuntimeEvent,
9+
ProviderFollowUpInput,
910
ProviderSendTurnInput,
1011
ProviderSession,
1112
ProviderTurnStartResult,
@@ -368,13 +369,27 @@ function makeFakeCodexAdapter(
368369
}),
369370
);
370371

372+
const followUp = vi.fn(
373+
(
374+
input: ProviderFollowUpInput,
375+
): Effect.Effect<SessionInputQueueUpdatedPayload, ProviderAdapterError> => {
376+
if (!sessions.has(input.threadId)) {
377+
return Effect.fail(
378+
new ProviderAdapterSessionNotFoundError({ provider, threadId: input.threadId }),
379+
);
380+
}
381+
return Effect.succeed(inputQueue);
382+
},
383+
);
384+
371385
const adapter: ProviderAdapterShape<ProviderAdapterError> = {
372386
provider,
373387
capabilities: {
374388
sessionModelSwitch: "in-session",
375389
},
376390
startSession,
377391
sendTurn,
392+
followUp,
378393
interruptTurn,
379394
respondToRequest,
380395
respondToUserInput,
@@ -430,6 +445,7 @@ function makeFakeCodexAdapter(
430445
updateSession,
431446
startSession,
432447
sendTurn,
448+
followUp,
433449
respondToInteraction,
434450
reloadSessionResources,
435451
askSessionSideQuestion,
@@ -1588,6 +1604,20 @@ routing.layer("ProviderServiceLive routing", (it) => {
15881604
assert.include(fileOnlyInput.input ?? "", '[Attached file "report.pdf" is saved at: ');
15891605
assert.deepEqual(fileOnlyInput.attachments, [fileAttachment]);
15901606

1607+
// Follow-ups need the same path lines. Every adapter except OpenCode skips
1608+
// non-image attachments, so without this a file attached to a queued
1609+
// follow-up reaches the agent as nothing at all.
1610+
yield* provider.followUp({
1611+
threadId: session.threadId,
1612+
input: "and this one",
1613+
attachments: [fileAttachment],
1614+
});
1615+
const followUpInput = routing.codex.followUp.mock.calls[0]?.[0] as ProviderFollowUpInput;
1616+
assert.include(followUpInput.input ?? "", "and this one");
1617+
assert.include(followUpInput.input ?? "", '[Attached file "report.pdf" is saved at: ');
1618+
assert.include(followUpInput.input ?? "", `${fileAttachment.id}.pdf]`);
1619+
assert.deepEqual(followUpInput.attachments, [fileAttachment]);
1620+
15911621
yield* provider.stopSession({ threadId: session.threadId });
15921622
}),
15931623
);

apps/server/src/provider/Layers/ProviderService.ts

Lines changed: 55 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,43 @@ const correlateRuntimeEventWithInstance = (
238238
return { ...event, providerInstanceId: source.instanceId };
239239
};
240240

241+
/**
242+
* Appends an on-disk path line for every attachment so the model's tools can
243+
* dereference the actual file.
244+
*
245+
* Every attachment also reaches the adapter, and each adapter decides what its
246+
* provider ingests natively: OpenCode sends generic files as file parts, the
247+
* others send images only and rely on these lines for everything else. That
248+
* makes the path line the sole channel a non-image attachment has on those
249+
* providers, which is why follow-ups need it exactly as much as turns do.
250+
*
251+
* Unresolvable ids are skipped here and surface as adapter errors when the file
252+
* is read.
253+
*/
254+
const appendAttachmentPathLines = (
255+
attachmentsDir: string,
256+
input: string | undefined,
257+
attachments: ReadonlyArray<{
258+
readonly id: string;
259+
readonly type: string;
260+
readonly name: string;
261+
}>,
262+
): string | undefined => {
263+
const lines = attachments.flatMap((attachment) => {
264+
const attachmentPath = resolveAttachmentPath({
265+
attachmentsDir,
266+
attachment: attachment as Parameters<typeof resolveAttachmentPath>[0]["attachment"],
267+
});
268+
return attachmentPath === null
269+
? []
270+
: [`[Attached ${attachment.type} "${attachment.name}" is saved at: ${attachmentPath}]`];
271+
});
272+
if (lines.length === 0) return input;
273+
return [input, lines.join("\n")]
274+
.filter((part): part is string => typeof part === "string" && part.length > 0)
275+
.join("\n\n");
276+
};
277+
241278
const makeProviderService = Effect.fn("makeProviderService")(function* (
242279
options?: ProviderServiceLiveOptions,
243280
) {
@@ -773,27 +810,11 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
773810
);
774811
}
775812

776-
// Every attachment gets an on-disk path in the prompt so the model's tools
777-
// can dereference the actual file. All attachments then go to the adapter,
778-
// and each adapter decides what its provider ingests natively: OpenCode
779-
// sends generic files as file parts, the others send images only and rely
780-
// on the path line for everything else. Unresolvable ids are skipped here
781-
// and surface as adapter errors when the file is read.
782-
const attachmentPathLines = attachments.flatMap((attachment) => {
783-
const attachmentPath = resolveAttachmentPath({
784-
attachmentsDir: serverConfig.attachmentsDir,
785-
attachment,
786-
});
787-
return attachmentPath === null
788-
? []
789-
: [`[Attached ${attachment.type} "${attachment.name}" is saved at: ${attachmentPath}]`];
790-
});
791-
const inputTextWithAttachmentPaths =
792-
attachmentPathLines.length === 0
793-
? parsed.input
794-
: [parsed.input, attachmentPathLines.join("\n")]
795-
.filter((part): part is string => typeof part === "string" && part.length > 0)
796-
.join("\n\n");
813+
const inputTextWithAttachmentPaths = appendAttachmentPathLines(
814+
serverConfig.attachmentsDir,
815+
parsed.input,
816+
attachments,
817+
);
797818

798819
const input = {
799820
...parsed,
@@ -1337,7 +1358,19 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
13371358
"provider.thread_id": input.threadId,
13381359
"provider.attachment_count": input.attachments.length,
13391360
});
1340-
return yield* followUpSession(input);
1361+
// Same path lines `sendTurn` appends. Without them a generic file attached
1362+
// to a follow-up is lost outright: every adapter except OpenCode skips
1363+
// non-images, so the path line is the only thing that tells the agent the
1364+
// file exists.
1365+
const followUpInputText = appendAttachmentPathLines(
1366+
serverConfig.attachmentsDir,
1367+
input.input,
1368+
input.attachments,
1369+
);
1370+
return yield* followUpSession({
1371+
...input,
1372+
...(followUpInputText !== undefined ? { input: followUpInputText } : {}),
1373+
});
13411374
});
13421375

13431376
const getSessionInputQueue: ProviderServiceMethod<"getSessionInputQueue"> = Effect.fn(

0 commit comments

Comments
 (0)