Skip to content

Commit 1ce569b

Browse files
authored
Merge pull request #59 from KochC/fix/text-part-fallback
fix: message.part.delta streaming, .info shape for tokens/finish, parts fallback for empty content
2 parents 9cdd662 + 522663c commit 1ce569b

2 files changed

Lines changed: 193 additions & 39 deletions

File tree

index.js

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -776,19 +776,26 @@ async function runAgentTurn(client, model, messages, system, callerTools, onChun
776776

777777
try {
778778
for await (const event of stream) {
779-
if (event.type === "message.part.updated") {
779+
if (event.type === "message.part.delta") {
780+
// Real incremental token deltas arrive here, as flat properties (sessionID,
781+
// partID, field, delta) - NOT nested under event.properties.part like
782+
// message.part.updated below. This is the actual live-streaming source; the
783+
// fallback via session.messages() after the loop covers turns where OpenCode
784+
// doesn't emit these (see below).
785+
const props = event.properties
786+
if (
787+
props?.sessionID === sessionID &&
788+
props?.field === "text" &&
789+
typeof props.delta === "string" &&
790+
props.delta.length > 0
791+
) {
792+
content += props.delta
793+
onChunk?.(props.delta)
794+
}
795+
} else if (event.type === "message.part.updated") {
780796
const part = event.properties?.part
781-
const delta = event.properties?.delta
782797

783798
if (
784-
part?.sessionID === sessionID &&
785-
part?.type === "text" &&
786-
typeof delta === "string" &&
787-
delta.length > 0
788-
) {
789-
content += delta
790-
onChunk?.(delta)
791-
} else if (
792799
toolIDSet &&
793800
part?.sessionID === sessionID &&
794801
part?.type === "tool" &&
@@ -826,15 +833,26 @@ async function runAgentTurn(client, model, messages, system, callerTools, onChun
826833
throw new Error(errorMessage)
827834
}
828835

836+
// Each list item is { info: Message, parts: Part[] } - matching the shape
837+
// client.session.prompt() (the non-tool-calling path) already returns directly.
829838
const messagesResult = await client.session.messages({ path: { id: sessionID } })
830-
const assistantMsg = (messagesResult.data ?? []).filter((m) => m.role === "assistant").at(-1)
839+
const assistantEntry = (messagesResult.data ?? []).filter((m) => m.info?.role === "assistant").at(-1)
840+
const assistantInfo = assistantEntry?.info
841+
842+
// Fallback for turns where message.part.delta never fired (observed for some
843+
// multi-step turns, e.g. continuing a conversation with prior tool calls/results in
844+
// history): use the authoritative final text from the fetched message's parts
845+
// instead of leaving content empty.
846+
if (!content && !toolCall) {
847+
content = extractAssistantText(assistantEntry?.parts ?? [])
848+
}
831849

832850
return {
833851
sessionID,
834852
content,
835853
toolCall,
836-
tokens: assistantMsg?.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
837-
finish: toolCall ? "tool_calls" : assistantMsg?.finish,
854+
tokens: assistantInfo?.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
855+
finish: toolCall ? "tool_calls" : assistantInfo?.finish,
838856
}
839857
}
840858

index.test.js

Lines changed: 162 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,12 @@ function createStreamingClient(chunks) {
7878
messages: async () => ({
7979
data: [
8080
{
81-
role: "assistant",
82-
tokens: { input: 10, output: 5, reasoning: 0, cache: { read: 0, write: 0 } },
83-
finish: "end_turn",
81+
info: {
82+
role: "assistant",
83+
tokens: { input: 10, output: 5, reasoning: 0, cache: { read: 0, write: 0 } },
84+
finish: "end_turn",
85+
},
86+
parts: [],
8487
},
8588
],
8689
}),
@@ -337,16 +340,18 @@ test("missing messages field returns 400", async () => {
337340
test("stream: true returns SSE response", async () => {
338341
const events = [
339342
{
340-
type: "message.part.updated",
343+
type: "message.part.delta",
341344
properties: {
342-
part: { sessionID: "sess-123", type: "text" },
345+
sessionID: "sess-123",
346+
field: "text",
343347
delta: "Hello",
344348
},
345349
},
346350
{
347-
type: "message.part.updated",
351+
type: "message.part.delta",
348352
properties: {
349-
part: { sessionID: "sess-123", type: "text" },
353+
sessionID: "sess-123",
354+
field: "text",
350355
delta: " world",
351356
},
352357
},
@@ -1094,16 +1099,18 @@ test("POST /v1/responses instructions field is incorporated", async () => {
10941099
test("POST /v1/responses stream: true returns SSE lifecycle events", async () => {
10951100
const events = [
10961101
{
1097-
type: "message.part.updated",
1102+
type: "message.part.delta",
10981103
properties: {
1099-
part: { sessionID: "sess-123", type: "text" },
1104+
sessionID: "sess-123",
1105+
field: "text",
11001106
delta: "The answer",
11011107
},
11021108
},
11031109
{
1104-
type: "message.part.updated",
1110+
type: "message.part.delta",
11051111
properties: {
1106-
part: { sessionID: "sess-123", type: "text" },
1112+
sessionID: "sess-123",
1113+
field: "text",
11071114
delta: " is 42.",
11081115
},
11091116
},
@@ -1137,16 +1144,18 @@ test("POST /v1/responses stream: true returns SSE lifecycle events", async () =>
11371144
test("POST /v1/responses stream: true emits content_part.done with accumulated text per OpenAI spec", async () => {
11381145
const events = [
11391146
{
1140-
type: "message.part.updated",
1147+
type: "message.part.delta",
11411148
properties: {
1142-
part: { sessionID: "sess-123", type: "text" },
1149+
sessionID: "sess-123",
1150+
field: "text",
11431151
delta: "The answer",
11441152
},
11451153
},
11461154
{
1147-
type: "message.part.updated",
1155+
type: "message.part.delta",
11481156
properties: {
1149-
part: { sessionID: "sess-123", type: "text" },
1157+
sessionID: "sess-123",
1158+
field: "text",
11501159
delta: " is 42.",
11511160
},
11521161
},
@@ -1635,16 +1644,18 @@ test("POST /v1/messages malformed JSON returns 400", async () => {
16351644
test("POST /v1/messages stream: true returns Anthropic SSE events", async () => {
16361645
const events = [
16371646
{
1638-
type: "message.part.updated",
1647+
type: "message.part.delta",
16391648
properties: {
1640-
part: { sessionID: "sess-123", type: "text" },
1649+
sessionID: "sess-123",
1650+
field: "text",
16411651
delta: "Hello",
16421652
},
16431653
},
16441654
{
1645-
type: "message.part.updated",
1655+
type: "message.part.delta",
16461656
properties: {
1647-
part: { sessionID: "sess-123", type: "text" },
1657+
sessionID: "sess-123",
1658+
field: "text",
16481659
delta: " world",
16491660
},
16501661
},
@@ -1832,16 +1843,18 @@ test("POST /v1beta/models/:model:generateContent malformed JSON returns 400", as
18321843
test("POST /v1beta/models/:model:streamGenerateContent returns NDJSON stream", async () => {
18331844
const events = [
18341845
{
1835-
type: "message.part.updated",
1846+
type: "message.part.delta",
18361847
properties: {
1837-
part: { sessionID: "sess-123", type: "text" },
1848+
sessionID: "sess-123",
1849+
field: "text",
18381850
delta: "Gem",
18391851
},
18401852
},
18411853
{
1842-
type: "message.part.updated",
1854+
type: "message.part.delta",
18431855
properties: {
1844-
part: { sessionID: "sess-123", type: "text" },
1856+
sessionID: "sess-123",
1857+
field: "text",
18451858
delta: "ini",
18461859
},
18471860
},
@@ -2099,9 +2112,12 @@ function createToolCallClient({ toolName, toolArgs, callID = "call_1", finish =
20992112
messages: async () => ({
21002113
data: [
21012114
{
2102-
role: "assistant",
2103-
tokens: { input: 5, output: 2, reasoning: 0, cache: { read: 0, write: 0 } },
2104-
finish,
2115+
info: {
2116+
role: "assistant",
2117+
tokens: { input: 5, output: 2, reasoning: 0, cache: { read: 0, write: 0 } },
2118+
finish,
2119+
},
2120+
parts: [],
21052121
},
21062122
],
21072123
}),
@@ -2228,6 +2244,126 @@ test("POST /v1/messages stream: true emits a tool_use content block", async () =
22282244
assert.ok(text.includes('"stop_reason":"tool_use"'))
22292245
})
22302246

2247+
// Regression test for: OpenCode delivers real incremental streaming text via
2248+
// message.part.delta events (flat properties: sessionID, partID, field, delta) - a
2249+
// completely separate event type from message.part.updated, which only carries status/
2250+
// snapshot updates (used here for tool-call detection). For some turns (observed with a
2251+
// multi-message conversation history, e.g. continuing after a prior tool call/result),
2252+
// OpenCode never emits message.part.delta at all for the final reply - only
2253+
// message.part.updated snapshots - so relying on message.part.delta alone would silently
2254+
// produce empty content. The fallback: after the loop, fetch the session's messages
2255+
// (client.session.messages()) and extract the final text from the assistant message's
2256+
// parts array directly, exactly as the non-tool-calling path already does via
2257+
// extractAssistantText().
2258+
//
2259+
// Each list item from client.session.messages() is `{ info: Message, parts: Part[] }` -
2260+
// info.role/info.tokens/info.finish, NOT flat role/tokens/finish directly on the item.
2261+
function createToolAwareTextClient({ events, assistantParts, tokens, finish }) {
2262+
return {
2263+
app: { log: async () => {} },
2264+
tool: { ids: async () => ({ data: [] }) },
2265+
config: {
2266+
providers: async () => ({
2267+
data: { providers: [{ id: "openai", models: { "gpt-4o": { id: "gpt-4o", name: "GPT-4o" } } }] },
2268+
}),
2269+
},
2270+
mcp: {
2271+
disconnect: async () => {
2272+
throw new Error("not connected")
2273+
},
2274+
add: async () => ({ data: {} }),
2275+
},
2276+
session: {
2277+
create: async () => ({ data: { id: "sess-text-1" } }),
2278+
promptAsync: async () => {},
2279+
abort: async () => ({ data: true }),
2280+
messages: async () => ({
2281+
data: [
2282+
{
2283+
info: { role: "assistant", tokens, finish },
2284+
parts: assistantParts,
2285+
},
2286+
],
2287+
}),
2288+
},
2289+
event: {
2290+
subscribe: async () => ({
2291+
stream: (async function* () {
2292+
for (const event of events) yield event
2293+
})(),
2294+
}),
2295+
},
2296+
}
2297+
}
2298+
2299+
test("POST /v1/chat/completions falls back to the final message's parts when message.part.delta never fires", async () => {
2300+
const client = createToolAwareTextClient({
2301+
events: [{ type: "session.idle", properties: { sessionID: "sess-text-1" } }],
2302+
assistantParts: [{ type: "step-start" }, { type: "text", text: "Agentic AI startups raise record funding" }],
2303+
tokens: { input: 42, output: 7, reasoning: 0, cache: { read: 0, write: 0 } },
2304+
finish: "stop",
2305+
})
2306+
const handler = createProxyFetchHandler(client)
2307+
const request = new Request("http://127.0.0.1:4010/v1/chat/completions", {
2308+
method: "POST",
2309+
headers: { "content-type": "application/json" },
2310+
body: JSON.stringify({
2311+
model: "gpt-4o",
2312+
messages: [
2313+
{ role: "user", content: "Search the web then summarize." },
2314+
{
2315+
role: "assistant",
2316+
content: null,
2317+
tool_calls: [{ id: "call_1", type: "function", function: { name: "search", arguments: "{}" } }],
2318+
},
2319+
{ role: "tool", tool_call_id: "call_1", content: "some search result" },
2320+
],
2321+
tools: [{ type: "function", function: { name: "search" } }],
2322+
}),
2323+
})
2324+
2325+
const response = await handler(request)
2326+
const body = await response.json()
2327+
2328+
assert.equal(response.status, 200)
2329+
assert.equal(body.choices[0].message.content, "Agentic AI startups raise record funding")
2330+
assert.equal(body.choices[0].finish_reason, "stop")
2331+
// Also locks in the .info.tokens fix - these were always read as undefined (silently
2332+
// falling back to all-zero usage) before, since the flat .tokens field this code used
2333+
// to read doesn't exist on the real API's { info, parts } shape.
2334+
assert.equal(body.usage.prompt_tokens, 42)
2335+
assert.equal(body.usage.completion_tokens, 7)
2336+
})
2337+
2338+
test("POST /v1/chat/completions accumulates content from message.part.delta events", async () => {
2339+
const client = createToolAwareTextClient({
2340+
events: [
2341+
{ type: "message.part.delta", properties: { sessionID: "sess-text-1", field: "text", delta: "Hello" } },
2342+
{ type: "message.part.delta", properties: { sessionID: "sess-text-1", field: "text", delta: " world" } },
2343+
{ type: "session.idle", properties: { sessionID: "sess-text-1" } },
2344+
],
2345+
assistantParts: [{ type: "text", text: "Hello world" }],
2346+
tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
2347+
finish: "stop",
2348+
})
2349+
const handler = createProxyFetchHandler(client)
2350+
const request = new Request("http://127.0.0.1:4010/v1/chat/completions", {
2351+
method: "POST",
2352+
headers: { "content-type": "application/json" },
2353+
body: JSON.stringify({
2354+
model: "gpt-4o",
2355+
messages: [{ role: "user", content: "Say hello world." }],
2356+
tools: [{ type: "function", function: { name: "search" } }],
2357+
}),
2358+
})
2359+
2360+
const response = await handler(request)
2361+
const body = await response.json()
2362+
2363+
assert.equal(response.status, 200)
2364+
assert.equal(body.choices[0].message.content, "Hello world")
2365+
})
2366+
22312367
// Regression test for: a bridge slot reused by an earlier turn stays connected under its
22322368
// old tool schema (OpenCode has no MCP deregistration endpoint), and getDisabledTools()
22332369
// only snapshots OpenCode's built-in tool IDs once, before any bridge tools ever exist -

0 commit comments

Comments
 (0)