Skip to content

Commit 307aba8

Browse files
Sean SmithSean Smith
authored andcommitted
fix(session): preserve structured plugin messages
Request-only suffixes join after AI SDK prompt lowering. Keep appends with non-text parts in the normal conversion path so files and tool content retain upstream lowering semantics.
1 parent b42157a commit 307aba8

2 files changed

Lines changed: 71 additions & 19 deletions

File tree

packages/opencode/src/session/message-v2.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -447,9 +447,22 @@ export const toModelMessagesSplitEffect = Effect.fnUntraced(function* (
447447
return { messages: yield* convert(filtered), tail: [] }
448448
}
449449

450+
// The AI SDK lowers ModelMessage into a provider prompt before middleware runs. The suffix joins
451+
// inside that middleware, so only text (plus the step boundary that converts into text messages)
452+
// is already in the same shape on both sides of that lowering. Keep richer plugin appends in the
453+
// main array rather than risk changing files, tool results, or other structured content in flight.
454+
const tail = filtered.slice(split)
455+
if (
456+
!tail.every((message) =>
457+
message.parts.every((part) => part.type === "step-start" || (part.type === "text" && part.text !== "")),
458+
)
459+
) {
460+
return { messages: yield* convert(filtered), tail: [] }
461+
}
462+
450463
return {
451464
messages: yield* convert(filtered.slice(0, split)),
452-
tail: yield* convert(filtered.slice(split)),
465+
tail: yield* convert(tail),
453466
}
454467
})
455468

packages/opencode/test/session/message-v2.test.ts

Lines changed: 57 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1794,7 +1794,7 @@ describe("session.message-v2 request-only partition", () => {
17941794
})
17951795

17961796
test("keeps every provider message expanded from a request-only source in the suffix", async () => {
1797-
const assistantID = "m-tail-tool"
1797+
const assistantID = "m-tail-split"
17981798
const input: SessionV1.WithParts[] = [
17991799
{
18001800
info: userInfo("m-durable"),
@@ -1803,32 +1803,71 @@ describe("session.message-v2 request-only partition", () => {
18031803
{
18041804
info: assistantInfo(assistantID, "m-durable"),
18051805
parts: [
1806-
{ ...basePart(assistantID, "text"), type: "text", text: "request-only answer" },
1806+
{ ...basePart(assistantID, "first"), type: "text", text: "request-only first" },
1807+
{ ...basePart(assistantID, "step"), type: "step-start" },
1808+
{ ...basePart(assistantID, "second"), type: "text", text: "request-only second" },
1809+
] as SessionV1.Part[],
1810+
},
1811+
]
1812+
1813+
const output = await Effect.runPromise(
1814+
MessageV2.toModelMessagesSplitEffect(input, model, { requestOnlyTailCount: 1 }),
1815+
)
1816+
expect(output.messages).toEqual([{ role: "user", content: [{ type: "text", text: "durable" }] }])
1817+
expect(output.tail.map((message) => message.role)).toEqual(["assistant", "assistant"])
1818+
expect(JSON.stringify(output.tail)).toContain("request-only first")
1819+
expect(JSON.stringify(output.tail)).toContain("request-only second")
1820+
})
1821+
1822+
test("keeps structured request-only content in the normal AI SDK conversion path", async () => {
1823+
const input: SessionV1.WithParts[] = [
1824+
{
1825+
info: userInfo("m-durable"),
1826+
parts: [{ ...basePart("m-durable", "durable"), type: "text", text: "durable" }] as SessionV1.Part[],
1827+
},
1828+
{
1829+
info: userInfo("m-tail-file"),
1830+
parts: [
18071831
{
1808-
...basePart(assistantID, "tool"),
1809-
type: "tool",
1810-
callID: "call-request-only",
1811-
tool: "probe",
1812-
state: {
1813-
status: "completed",
1814-
input: { value: 1 },
1815-
output: "request-only output",
1816-
title: "Probe",
1817-
metadata: {},
1818-
time: { start: 0, end: 1 },
1819-
},
1832+
...basePart("m-tail-file", "file"),
1833+
type: "file",
1834+
mime: "image/png",
1835+
filename: "image.png",
1836+
url: "https://example.com/image.png",
18201837
},
18211838
] as SessionV1.Part[],
18221839
},
18231840
]
18241841

1842+
const bulk = await MessageV2.toModelMessages(input, model)
18251843
const output = await Effect.runPromise(
18261844
MessageV2.toModelMessagesSplitEffect(input, model, { requestOnlyTailCount: 1 }),
18271845
)
1828-
expect(output.messages).toEqual([{ role: "user", content: [{ type: "text", text: "durable" }] }])
1829-
expect(output.tail.map((message) => message.role)).toEqual(["assistant", "tool"])
1830-
expect(JSON.stringify(output.tail)).toContain("request-only answer")
1831-
expect(JSON.stringify(output.tail)).toContain("request-only output")
1846+
1847+
expect(output.messages).toEqual(bulk)
1848+
expect(output.tail).toEqual([])
1849+
})
1850+
1851+
test("keeps empty request-only text in the normal AI SDK conversion path", async () => {
1852+
const assistantID = "m-tail-empty"
1853+
const input: SessionV1.WithParts[] = [
1854+
{
1855+
info: userInfo("m-durable"),
1856+
parts: [{ ...basePart("m-durable", "durable"), type: "text", text: "durable" }] as SessionV1.Part[],
1857+
},
1858+
{
1859+
info: assistantInfo(assistantID, "m-durable"),
1860+
parts: [{ ...basePart(assistantID, "empty"), type: "text", text: "" }] as SessionV1.Part[],
1861+
},
1862+
]
1863+
1864+
const bulk = await MessageV2.toModelMessages(input, model)
1865+
const output = await Effect.runPromise(
1866+
MessageV2.toModelMessagesSplitEffect(input, model, { requestOnlyTailCount: 1 }),
1867+
)
1868+
1869+
expect(output.messages).toEqual(bulk)
1870+
expect(output.tail).toEqual([])
18321871
})
18331872
})
18341873

0 commit comments

Comments
 (0)