189161ed62
Lands the streaming-dispatch tool loop for the LLM-native path. When
the gate-passing session has `nativeTools` populated, the native
runner forks an AI SDK `tool.execute(...)` the moment a `tool-call`
event arrives mid-stream and injects a synthetic `tool-result` event
back into the same stream when the handler resolves. Long-running
tools no longer block subsequent tool-call streaming; the user sees
each result land as soon as that specific handler completes.
The driver loops across rounds: when a round ends with `reason:
"tool-calls"` AND the dispatchers produced at least one result, the
runner builds a continuation `LLMRequest` (assistant message echoing
text/reasoning/tool-call content + tool messages with results) and
recurses. Stops on a non-`tool-calls` finish, when `maxSteps`
(default 10, mirrors `ToolRuntime.run`) is reached, or when the
underlying scope is interrupted.
New file `session/llm-native-tools.ts`:
- `runWithTools({ client, request, tools, abort, maxSteps? })` is the
public entry point. Returns a `Stream<LLMEvent, LLMError,
RequestExecutor.Service>` of merged model events + synthetic tool
results, ready to flow through `LLMNativeEvents.mapper` for
consumption by the existing session processor.
- `runOneRound` is the internal building block. It opens an unbounded
`Queue<LLMEvent, LLMError | Cause.Done>`, forks a producer that
streams the model and pushes each event to the queue, and forks a
dispatcher (via a scope-bound `FiberSet`) for every
non-provider-executed `tool-call`. Each dispatcher's result is
pushed back into the same queue. After the model stream completes,
the producer awaits `FiberSet.awaitEmpty` and ends the queue;
consumers see end-of-stream. A `Deferred<RoundState>` resolves
alongside so the multi-round driver can decide whether to recurse.
- `dispatchTool` wraps the AI SDK `tool.execute(input, { toolCallId,
messages, abortSignal })` call. Unknown-tool and execute-throws
paths produce `tool-error` events instead of failing the stream
(mirrors `ToolRuntime.run`'s defect-vs-recoverable boundary), so
the model can self-correct on the next round.
Wired into `runNative` (`session/llm.ts`): when `input.nativeTools`
is non-empty, the upstream becomes `LLMNativeTools.runWithTools(...)`
instead of `nativeClient.stream(...)`; the AI SDK `tools` record
flows in as the dispatch table. Zero-tool sessions still take the
direct-stream path (one round, no dispatch overhead).
Mapper update (`session/llm-native-events.ts`): `tool-result` events
whose `result.value` matches the opencode `Tool.ExecuteResult` shape
(`{ output: string, title?: string, metadata?: object }`) now flow
through to the AI-SDK-shaped session event with their `title` and
`metadata` preserved. Provider-executed and synthetic results that
don't match still fall back to `stringifyResult`. Without this, the
session processor would see every native tool result as
`{ title: "", metadata: {}, output: <JSON of the whole record> }`.
Smoke test (`test/session/llm-native-stream.test.ts`): scripts a
two-round Anthropic SSE backend — round 1 issues a `lookup` tool
call, round 2 replies with text after the tool result feeds back.
Asserts the full event sequence threads through `runWithTools`,
the dispatcher, and the mapper:
- `tool-call` event has the streamed JSON input parsed.
- `tool-result` event carries the `ExecuteResult` shape with
`title` + `output` populated (proving the mapper update works).
- Round 2 text-delta arrives after the synthetic tool-result.
- Final `finish` event has `finishReason: "stop"` (loop terminated).
What this still does NOT do (deferred to step 3):
- No production caller populates `nativeTools` yet; that's the
`prompt.ts:resolveTools` change. Until that lands, the gate keeps
every real session on the AI SDK path.
- No parity harness comparing native + AI SDK event sequences for
the same scripted session. That's step 4.
Verification: opencode typecheck clean; 36/0/0 across the three
bridge-area tests; 125/0/0 across the LLM package.