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) }, + ), +)