Skip to content

[FEATURE]: Emit message.part.delta for tool-input-delta events #28800

@FurryWolfX

Description

@FurryWolfX

Feature hasn't been suggested before.

  • I have verified this feature I'm about to request hasn't been suggested before.

Describe the enhancement you want to request

Problem

When an LLM streams tool call arguments (e.g., the file content for write/edit tools), the opencode session processor silently drops the tool-input-delta events at packages/opencode/src/session/processor.ts:357:

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.
  return

This means:

  • Real-time TPS tracking is unavailable during tool argument generation. TUI plugins that rely on message.part.delta events (like the oc-tps TPS tracker) show stale data or - while the AI is writing file content, because no delta events are emitted for tool parts.
  • Any future event-driven feature that depends on observing tool argument streaming (progress indicators, live preview, etc.) is blocked by this gap.

The tool-input-delta events exist in the LLM event pipeline (LLMEvent.toolInputDelta() at packages/llm/src/schema/events.ts:135) and contain the streaming text fragments — they're just not forwarded to the session layer.

Context

The reasoning-delta handler at lines 329–341 shows the established pattern:

case "reasoning-delta":
  if (!(value.id in ctx.reasoningMap)) return
  ctx.reasoningMap[value.id].text += value.text
  yield* session.updatePartDelta({
    sessionID: ctx.reasoningMap[value.id].sessionID,
    messageID: ctx.reasoningMap[value.id].messageID,
    partID: ctx.reasoningMap[value.id].id,
    field: "text",
    delta: value.text,
  })
  return

The tool-input-delta case could follow the same approach since:

  • The tool part is already created by ensureToolCall() during tool-input-start with state: { status: "pending", input: {}, raw: "" }.
  • readToolCall(value.id) can retrieve the existing part.
  • updateToolCall(value.id, ...) can accumulate the delta into part.state.raw.
  • session.updatePartDelta(...) can emit the event with an appropriate field name.

Additionally, the comment "accumulating delta fragments into state.raw is redundant work for no current consumer" is inaccuratestate.raw is already consumed by:

  • packages/app/src/components/session/session-context-breakdown.ts:29 — reads part.state.raw.length for token counting
  • packages/opencode/src/cli/cmd/run/subagent-data.ts:202 — serializes part.state.raw in subagent data
  • packages/opencode/src/cli/cmd/export.ts:100 — includes part.state.raw in export output

And the ToolStatePending type definition at packages/sdk/js/src/v2/gen/types.gen.ts:573 already includes a raw: string field, confirming this was the intended design.

Request

Make the tool-input-delta handler emit message.part.delta events and accumulate the delta text into part.state.raw, mirroring the reasoning-delta pattern. This would enable real-time TPS tracking, live progress indicators, and other event-driven features during tool argument generation.

Proposed Solution

In packages/opencode/src/session/processor.ts, replace the no-op tool-input-delta case with something like:

case "tool-input-delta": {
  const match = yield* readToolCall(value.id)
  if (!match) return
  const part = yield* session.updatePart({
    ...match.part,
    state: { ...match.part.state, status: "pending" as const, raw: match.part.state.raw + value.text },
  })
  yield* session.updatePartDelta({
    sessionID: match.part.sessionID,
    messageID: match.part.messageID,
    partID: match.part.id,
    field: "tool_input",
    delta: value.text,
  })
  return
}

The field name "tool_input" distinguishes tool-argument deltas from text/reasoning deltas in downstream consumers.

Open questions

  1. Should the delta field be "tool_input" or something more generic (e.g., just "text" like reasoning-delta)? A distinct name makes it easier for consumers to differentiate, but "text" simplifies the assumption that any message.part.delta with field === "text" represents token generation.
  2. Are there any performance concerns with firing per-character deltas for large tool inputs (e.g., writing a 10k-line file)? The existing text-delta and reasoning-delta handlers fire at the same granularity, so this should be consistent.
  3. Should state.raw accumulation be toggleable via a config flag to avoid overhead for consumers that don't need it?

Additional Notes

The LLM.Event.ToolInputDelta schema at packages/llm/src/schema/events.ts:135 already contains the delta text and tool name:

export const ToolInputDelta = Schema.Struct({
  type: Schema.tag("tool-input-delta"),
  id: ToolCallID,
  name: Schema.String,
  text: Schema.String,
});

All necessary data is available — only the processing logic in processor.ts needs to change. The SDK types, event schemas, and LLM event pipeline already support this feature.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions