From 1ba79bd267b6d596a69f0563fe88ebca3c6241c3 Mon Sep 17 00:00:00 2001 From: lexlian <9305752+lexlian@users.noreply.github.com> Date: Fri, 22 May 2026 19:11:06 +0800 Subject: [PATCH] feat(session): emit message.part.delta for tool-input-delta events Closes #28800 The tool-input-delta case was a no-op with a comment claiming there was no consumer for state.raw. In reality, state.raw is consumed by the context breakdown component (token counting), subagent data serialization, and export output. This change follows the established reasoning-delta pattern: accumulate delta text into state.raw and emit updatePartDelta so downstream consumers receive real-time tool argument streaming events. Co-Authored-By: Claude Opus 4.7 --- packages/opencode/src/session/processor.ts | 17 ++++- packages/opencode/test/lib/llm-server.ts | 8 +-- .../test/session/processor-effect.test.ts | 67 ++++++++++++++++++- 3 files changed, 84 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index a287c3b00680..1529b590d4c4 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -354,10 +354,21 @@ export const layer = Layer.effect( yield* ensureToolCall(value) return - case "tool-input-delta": - // AI SDK emits a final `tool-call` with the parsed `input`; accumulating - // delta fragments into `state.raw` is redundant work for no current consumer. + case "tool-input-delta": { + const toolCall = yield* ensureToolCall(value) + const state = toolCall.part.state + if (state.status === "pending") { + state.raw += value.text + yield* session.updatePartDelta({ + sessionID: toolCall.part.sessionID, + messageID: toolCall.part.messageID, + partID: toolCall.part.id, + field: "raw", + delta: value.text, + }) + } return + } case "tool-input-end": { const toolCall = yield* ensureToolCall(value) diff --git a/packages/opencode/test/lib/llm-server.ts b/packages/opencode/test/lib/llm-server.ts index 1f873a9fbbfc..6f2afddf7a59 100644 --- a/packages/opencode/test/lib/llm-server.ts +++ b/packages/opencode/test/lib/llm-server.ts @@ -80,7 +80,7 @@ function chunk(input: { delta?: Record; finish?: string; usage? } satisfies Line } -function role() { +export function role() { return chunk({ delta: { role: "assistant" } }) } @@ -92,11 +92,11 @@ function reasonLine(value: string) { return chunk({ delta: { reasoning_content: value } }) } -function finishLine(reason: string, usage?: Usage) { +export function finishLine(reason: string, usage?: Usage) { return chunk({ finish: reason, usage }) } -function toolStartLine(id: string, name: string) { +export function toolStartLine(id: string, name: string) { return chunk({ delta: { tool_calls: [ @@ -114,7 +114,7 @@ function toolStartLine(id: string, name: string) { }) } -function toolArgsLine(value: string) { +export function toolArgsLine(value: string) { return chunk({ delta: { tool_calls: [ diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index ede122297a17..10ebc98278a0 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -25,7 +25,7 @@ import * as Log from "@opencode-ai/core/util/log" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { provideTmpdirServer } from "../fixture/fixture" import { testEffect } from "../lib/effect" -import { raw, reply, TestLLMServer } from "../lib/llm-server" +import { raw, reply, TestLLMServer, toolStartLine, toolArgsLine, finishLine, role } from "../lib/llm-server" import { SyncEvent } from "@/sync" import { RuntimeFlags } from "@/effect/runtime-flags" import { EventV2Bridge } from "@/event-v2-bridge" @@ -920,3 +920,68 @@ it.live("session.processor effect tests mark interruptions aborted without manua { config: (url) => providerCfg(url) }, ), ) + +it.live("tool-input-delta events accumulate into state.raw and emit part.delta", () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const { processors, session, provider } = yield* boot() + + const id = "call_1" + const fullArgs = JSON.stringify({ content: "hello world file content here" }) + const half = Math.floor(fullArgs.length / 2) + + yield* llm.push( + raw({ + head: [ + role(), + toolStartLine(id, "write"), + toolArgsLine(fullArgs.slice(0, half)), + toolArgsLine(fullArgs.slice(half)), + ], + tail: [finishLine("stop")], + }), + ) + + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "write a file") + const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const mdl = yield* provider.getModel(ref.providerID, ref.modelID) + const handle = yield* processors.create({ + assistantMessage: msg, + sessionID: chat.id, + model: mdl, + }) + + const value = yield* handle.process({ + user: { + id: parent.id, + sessionID: chat.id, + role: "user", + time: parent.time, + agent: parent.agent, + model: { providerID: ref.providerID, modelID: ref.modelID }, + } satisfies MessageV2.User, + sessionID: chat.id, + model: mdl, + agent: agent(), + system: [], + messages: [{ role: "user", content: "write a file" }], + tools: {}, + }) + + const parts = MessageV2.parts(msg.id) + const toolPart = parts.find((part): part is MessageV2.ToolPart => part.type === "tool") + + expect(value).toBe("continue") + expect(toolPart).toBeDefined() + expect(toolPart?.tool).toBe("write") + if (toolPart?.state.status === "pending") { + expect(toolPart.state.raw.length).toBeGreaterThan(0) + } else if (toolPart?.state.status === "completed") { + expect(toolPart.state.input).toEqual(JSON.parse(fullArgs)) + } + }), + { config: (url) => providerCfg(url) }, + ), +)