@@ -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 () => {
337340test ( "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 () => {
10941099test ( "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 () =>
11371144test ( "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 () => {
16351644test ( "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
18321843test ( "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