@opencode-ai/ai 0.0.0-beta-18414 → 0.0.0-beta-18593

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -214,22 +214,40 @@ the requests sent by code under test:
214
214
  import { Effect } from "effect"
215
215
  import { TestLLM } from "@opencode-ai/ai/testing"
216
216
 
217
- const testLLM = TestLLM.layer({
218
- fallback: TestLLM.text("Hello from the test model", "text-1"),
219
- })
220
-
221
- // TestLLM.clientLayer provides LLMClient.Service and consumes TestLLM.Service.
222
217
  const programWithTestClient = Effect.gen(function* () {
218
+ const test = yield* TestLLM.Test
219
+ yield* test.push(TestLLM.text("Hello from the test model", "text-1"))
223
220
  const result = yield* program
224
- const test = yield* TestLLM.Service
225
- console.log(test.requests)
221
+ console.log(yield* test.requests())
226
222
  return result
227
- }).pipe(Effect.provide(TestLLM.clientLayer), Effect.provide(testLLM))
223
+ }).pipe(Effect.provide(TestLLM.testLayer()))
228
224
  ```
229
225
 
230
- `TestLLM.push(...)` scripts one-shot responses, `TestLLM.always(...)` changes the fallback, and
231
- `TestLLM.wait(...)` lets concurrent tests wait until a request has arrived. Every received canonical request is
232
- available on the yielded `TestLLM.Service`.
226
+ `testLayer()` provides the same object under `LLMClient.Service` and `TestLLM.Test`. Production consumes the
227
+ normal client; tests use the additional controls. Each layer build has fresh state.
228
+
229
+ - `test.push(...)` queues one-shot responses in execution order. Each argument is one response.
230
+ - `test.always(response)` installs a repeatable fallback. The layer's `fallback` option sets its initial value.
231
+ - `test.serve(request => response)` installs a request-dependent fallback. `always` and `serve` replace each
232
+ other without changing queued replies; queued replies take precedence.
233
+ - `test.requests()` returns an array snapshot. `transformRequest` changes only the recorded observation;
234
+ `serve` receives the original canonical request.
235
+ - `test.wait(count)` waits for request arrivals, not output or completion, and supports concurrent waiters.
236
+ - `test.gate()` returns a scoped gate with countable `started` notifications and a `release` Effect. Release
237
+ unblocks all requests captured by that gate; closing its scope also releases it. Effect-aware test runners
238
+ already provide Scope.
239
+
240
+ Constructing `stream()` or `generate()` does not record a request, invoke a responder, or consume a script.
241
+ Each execution does. An exhausted queue without a fallback defects immediately rather than waiting for a
242
+ future reply.
243
+
244
+ Responses remain canonical event arrays or arbitrary `Stream<LLMEvent, AIError>` values. The client consumes
245
+ supplied streams directly, preserving failure identity, finalizers, incomplete output, and post-finish tails;
246
+ it does not repair or truncate them.
247
+
248
+ The published legacy `Service`, `layer`, `clientLayer`, and module-level controls remain available as adapters
249
+ over the same implementation, including the legacy live `requests` array. New tests should use `Test` and
250
+ `testLayer`.
233
251
 
234
252
  ## Caching
235
253
 
package/dist/llm.d.ts CHANGED
@@ -45,6 +45,7 @@ export declare class GenerateObjectResponse<T> {
45
45
  } | {
46
46
  readonly id: string;
47
47
  readonly type: "text-end";
48
+ readonly text?: string | undefined;
48
49
  readonly providerMetadata?: {
49
50
  readonly [x: string]: {
50
51
  readonly [x: string]: unknown;
@@ -70,6 +71,7 @@ export declare class GenerateObjectResponse<T> {
70
71
  } | {
71
72
  readonly id: string;
72
73
  readonly type: "reasoning-end";
74
+ readonly text?: string | undefined;
73
75
  readonly providerMetadata?: {
74
76
  readonly [x: string]: {
75
77
  readonly [x: string]: unknown;
@@ -129,6 +129,7 @@ export type BedrockConverseBody = Schema.Schema.Type<typeof BedrockConverseBody>
129
129
  interface ParserState {
130
130
  readonly providerMetadataKey: string;
131
131
  readonly tools: ToolStream.State<number>;
132
+ readonly finishedTools: ReadonlySet<number>;
132
133
  readonly pendingFinish: {
133
134
  readonly reason: FinishReasonDetails;
134
135
  readonly usage?: Usage;
@@ -285,6 +285,13 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (requ
285
285
  content.push({ reasoningContent: { redactedContent: redactedData } });
286
286
  continue;
287
287
  }
288
+ if (signature === undefined || signature.trim().length === 0) {
289
+ // Interrupted streams and model switches can leave unsigned reasoning.
290
+ // Preserve readable history as text rather than replay invalid reasoningContent.
291
+ if (part.text.trim().length > 0)
292
+ content.push(...textWithCache(breakpoints, part.text, part.cache));
293
+ continue;
294
+ }
288
295
  content.push({ reasoningContent: { reasoningText: { text: part.text, signature } } });
289
296
  continue;
290
297
  }
@@ -293,7 +300,8 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (requ
293
300
  continue;
294
301
  }
295
302
  }
296
- messages.push({ role: "assistant", content });
303
+ if (content.length > 0)
304
+ messages.push({ role: "assistant", content });
297
305
  continue;
298
306
  }
299
307
  const content = [];
@@ -447,6 +455,8 @@ const step = (state, event) => Effect.gen(function* () {
447
455
  }
448
456
  if (event.contentBlockDelta?.delta?.toolUse) {
449
457
  const index = event.contentBlockDelta.contentBlockIndex;
458
+ if (state.finishedTools.has(index))
459
+ return [state, []];
450
460
  const result = ToolStream.appendExisting(ADAPTER, state.tools, index, event.contentBlockDelta.delta.toolUse.input, "Bedrock Converse tool delta is missing its tool call");
451
461
  if (ToolStream.isError(result))
452
462
  return yield* result;
@@ -473,6 +483,7 @@ const step = (state, event) => Effect.gen(function* () {
473
483
  state.hasToolCalls,
474
484
  lifecycle,
475
485
  tools: result.tools,
486
+ finishedTools: resultEvents.length > 0 ? new Set([...state.finishedTools, index]) : state.finishedTools,
476
487
  reasoningSignatures: Object.fromEntries(Object.entries(state.reasoningSignatures).filter(([key]) => key !== String(index))),
477
488
  },
478
489
  events,
@@ -552,6 +563,7 @@ export const protocol = Protocol.make({
552
563
  initial: (request) => ({
553
564
  providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
554
565
  tools: ToolStream.empty(),
566
+ finishedTools: new Set(),
555
567
  pendingFinish: undefined,
556
568
  hasToolCalls: false,
557
569
  lifecycle: Lifecycle.initial(),
@@ -565,15 +565,19 @@ export interface ParserState {
565
565
  readonly name: string;
566
566
  readonly providerMetadataKey: string;
567
567
  readonly tools: ToolStream.State<string>;
568
+ readonly completedTools: ReadonlySet<string>;
568
569
  readonly hasFunctionCall: boolean;
569
570
  readonly lifecycle: Lifecycle.State;
570
571
  readonly outputItems: Readonly<Record<number, string>>;
571
- readonly messageItems: ReadonlySet<string>;
572
- readonly messagePhases: Readonly<Record<string, MessagePhase | null>>;
572
+ readonly message: {
573
+ readonly id: string;
574
+ readonly phase: MessagePhase | null | undefined;
575
+ } | undefined;
573
576
  readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>;
574
577
  }
575
578
  type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded";
576
579
  interface ReasoningStreamItem {
580
+ readonly open: boolean;
577
581
  readonly encryptedContent: string | null | undefined;
578
582
  readonly summaryParts: Readonly<Record<number, ReasoningSummaryStatus>>;
579
583
  readonly deltaIndexes: ReadonlySet<number>;
@@ -828,6 +832,7 @@ export declare const step: (state: ParserState, input: Event) => AIError | Effec
828
832
  } | {
829
833
  readonly id: string;
830
834
  readonly type: "text-end";
835
+ readonly text?: string | undefined;
831
836
  readonly providerMetadata?: {
832
837
  readonly [x: string]: {
833
838
  readonly [x: string]: unknown;
@@ -853,6 +858,7 @@ export declare const step: (state: ParserState, input: Event) => AIError | Effec
853
858
  } | {
854
859
  readonly id: string;
855
860
  readonly type: "reasoning-end";
861
+ readonly text?: string | undefined;
856
862
  readonly providerMetadata?: {
857
863
  readonly [x: string]: {
858
864
  readonly [x: string]: unknown;
@@ -607,16 +607,16 @@ const NO_EVENTS = [];
607
607
  const TERMINAL_TYPES = new Set(["error", "response.completed", "response.incomplete", "response.failed"]);
608
608
  export const terminal = (event) => TERMINAL_TYPES.has(event.type);
609
609
  const onOutputTextDelta = (state, event, id) => {
610
- if (!event.delta || !state.messageItems.has(id))
610
+ if (!event.delta || state.message?.id !== id)
611
611
  return [state, NO_EVENTS];
612
612
  const events = [];
613
- const phase = state.messagePhases[id];
613
+ const phase = state.message.phase;
614
614
  const metadata = providerMetadata(state, { itemId: id, ...(phase === undefined ? {} : { phase }) });
615
615
  const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata);
616
616
  return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) }, events];
617
617
  };
618
618
  const onOutputTextDone = (state, event, id) => {
619
- if (state.messageItems.has(id)) {
619
+ if (state.message?.id === id) {
620
620
  if (state.lifecycle.text.has(id) || event.text === undefined)
621
621
  return [state, NO_EVENTS];
622
622
  return onOutputTextDelta(state, { ...event, delta: event.text }, id);
@@ -625,19 +625,51 @@ const onOutputTextDone = (state, event, id) => {
625
625
  return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events];
626
626
  };
627
627
  export const outputItemID = (state, event) => event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id);
628
- export const onReasoningDelta = (state, event, itemID) => {
628
+ const startReasoningSummaryPart = (state, itemID, index) => {
629
629
  const item = state.reasoningItems[itemID];
630
- if (!event.delta || !item)
630
+ if (!item?.open || index === 0 || item.summaryParts[index] !== undefined)
631
631
  return [state, NO_EVENTS];
632
- const index = event.summary_index ?? 0;
633
632
  const events = [];
633
+ const lifecycle = Object.entries(item.summaryParts)
634
+ .filter((entry) => entry[1] !== "concluded")
635
+ .reduce((lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${itemID}:${entry[0]}`, providerMetadata(state, { itemId: itemID })), state.lifecycle);
634
636
  return [
635
637
  {
636
638
  ...state,
637
- lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `${itemID}:${index}`, event.delta),
639
+ lifecycle: Lifecycle.reasoningStart(lifecycle, events, `${itemID}:${index}`, providerMetadata(state, { itemId: itemID, reasoningEncryptedContent: item.encryptedContent ?? null })),
638
640
  reasoningItems: {
639
641
  ...state.reasoningItems,
640
- [itemID]: { ...item, deltaIndexes: new Set([...item.deltaIndexes, index]) },
642
+ [itemID]: {
643
+ ...item,
644
+ summaryParts: {
645
+ ...Object.fromEntries(Object.entries(item.summaryParts).map((entry) => entry[1] === "concluded" ? entry : [entry[0], "concluded"])),
646
+ [index]: "active",
647
+ },
648
+ },
649
+ },
650
+ },
651
+ events,
652
+ ];
653
+ };
654
+ export const onReasoningDelta = (state, event, itemID) => {
655
+ const item = state.reasoningItems[itemID];
656
+ if (!event.delta || !item?.open)
657
+ return [state, NO_EVENTS];
658
+ const index = event.summary_index ?? 0;
659
+ if (item.summaryParts[index] === "concluded")
660
+ return [state, NO_EVENTS];
661
+ const [started, emitted] = startReasoningSummaryPart(state, itemID, index);
662
+ const current = started.reasoningItems[itemID];
663
+ if (!current)
664
+ return [started, emitted];
665
+ const events = [...emitted];
666
+ return [
667
+ {
668
+ ...started,
669
+ lifecycle: Lifecycle.reasoningDelta(started.lifecycle, events, `${itemID}:${index}`, event.delta),
670
+ reasoningItems: {
671
+ ...started.reasoningItems,
672
+ [itemID]: { ...current, deltaIndexes: new Set([...current.deltaIndexes, index]) },
641
673
  },
642
674
  },
643
675
  events,
@@ -648,7 +680,7 @@ export const onReasoningDelta = (state, event, itemID) => {
648
680
  // as a single delta unless that summary index already streamed one.
649
681
  export const onReasoningDone = (state, event, itemID) => {
650
682
  const item = state.reasoningItems[itemID];
651
- if (!item || typeof event.text !== "string")
683
+ if (!item?.open || typeof event.text !== "string")
652
684
  return [state, NO_EVENTS];
653
685
  const index = event.summary_index ?? 0;
654
686
  if (item.deltaIndexes.has(index))
@@ -656,32 +688,44 @@ export const onReasoningDone = (state, event, itemID) => {
656
688
  return onReasoningDelta(state, { ...event, delta: event.text }, itemID);
657
689
  };
658
690
  const reasoningMetadata = (state, item) => providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null });
659
- // Responses APIs stream reasoning items in a stable order:
691
+ // Responses APIs normally stream reasoning items in this order:
660
692
  // `output_item.added` (reasoning) →
661
693
  // `reasoning_summary_part.added` (index=0) →
662
694
  // `reasoning_summary_text.delta` →
663
695
  // `reasoning_summary_part.done` (index=0) →
664
696
  // (repeat for index>0) →
665
697
  // `output_item.done` (reasoning).
666
- // The handlers below rely on this ordering: `onOutputItemAdded` seeds the
667
- // per-item entry, `onReasoningSummaryPartAdded` for `summary_index === 0`
668
- // short-circuits when the entry already exists, and higher-index handlers
669
- // fold against the same entry. Behaviour for out-of-order events is
670
- // best-effort, not guaranteed.
698
+ // `onOutputItemAdded` seeds the per-item entry, while each later part start is
699
+ // also an implicit boundary for the previous part. This keeps the common event
700
+ // lifecycle ordered when a compatible provider omits or delays a part-done event.
671
701
  const onOutputItemAdded = (state, event) => {
672
702
  const item = event.item;
673
703
  if (item?.type === "message" && item.id !== undefined) {
704
+ const itemID = item.id;
674
705
  const phase = messagePhase(item.phase);
706
+ // A new message closes earlier messages, including ones that never streamed.
707
+ const events = [];
708
+ const lifecycle = [...state.lifecycle.text]
709
+ .filter((id) => id !== itemID)
710
+ .reduce((lifecycle, id) => {
711
+ const openPhase = state.message?.id === id ? state.message.phase : undefined;
712
+ return Lifecycle.textEnd(lifecycle, events, id, providerMetadata(state, { itemId: id, ...(openPhase === undefined ? {} : { phase: openPhase }) }));
713
+ }, state.lifecycle);
675
714
  return [
676
715
  {
677
716
  ...state,
678
- messageItems: new Set([...state.messageItems, item.id]),
679
- messagePhases: phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase },
717
+ lifecycle,
718
+ message: {
719
+ id: itemID,
720
+ phase: phase === undefined && state.message?.id === itemID ? state.message.phase : phase,
721
+ },
680
722
  },
681
- NO_EVENTS,
723
+ events,
682
724
  ];
683
725
  }
684
726
  if (item && isReasoningItem(item)) {
727
+ if (state.reasoningItems[item.id] !== undefined)
728
+ return [state, NO_EVENTS];
685
729
  const events = [];
686
730
  return [
687
731
  {
@@ -690,6 +734,7 @@ const onOutputItemAdded = (state, event) => {
690
734
  reasoningItems: {
691
735
  ...state.reasoningItems,
692
736
  [item.id]: {
737
+ open: true,
693
738
  encryptedContent: item.encrypted_content,
694
739
  summaryParts: { 0: "active" },
695
740
  deltaIndexes: new Set(),
@@ -702,6 +747,8 @@ const onOutputItemAdded = (state, event) => {
702
747
  if (item?.type !== "function_call" || !item.call_id)
703
748
  return [state, NO_EVENTS];
704
749
  const id = item.id ?? item.call_id;
750
+ if (Object.values(state.tools).some((tool) => tool?.id === item.call_id) || state.completedTools.has(item.call_id))
751
+ return [state, NO_EVENTS];
705
752
  const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined;
706
753
  const events = [];
707
754
  const lifecycle = Lifecycle.stepStart(state.lifecycle, events);
@@ -722,38 +769,15 @@ const onOutputItemAdded = (state, event) => {
722
769
  const onReasoningSummaryPartAdded = (state, event) => {
723
770
  if (event.item_id === undefined || event.summary_index === undefined)
724
771
  return [state, NO_EVENTS];
725
- const item = state.reasoningItems[event.item_id];
726
- if (!item)
727
- return [state, NO_EVENTS];
728
- if (event.summary_index === 0)
729
- return [state, NO_EVENTS];
730
- const events = [];
731
- const closed = Object.entries(item.summaryParts)
732
- .filter((entry) => entry[1] === "can-conclude")
733
- .reduce((lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${event.item_id}:${entry[0]}`, providerMetadata(state, { itemId: event.item_id })), state.lifecycle);
734
- return [
735
- {
736
- ...state,
737
- lifecycle: Lifecycle.reasoningStart(closed, events, `${event.item_id}:${event.summary_index}`, providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null })),
738
- reasoningItems: {
739
- ...state.reasoningItems,
740
- [event.item_id]: {
741
- ...item,
742
- summaryParts: {
743
- ...Object.fromEntries(Object.entries(item.summaryParts).map((entry) => entry[1] === "can-conclude" ? [entry[0], "concluded"] : entry)),
744
- [event.summary_index]: "active",
745
- },
746
- },
747
- },
748
- },
749
- events,
750
- ];
772
+ return startReasoningSummaryPart(state, event.item_id, event.summary_index);
751
773
  };
752
774
  const onReasoningSummaryPartDone = (state, event) => {
753
775
  if (event.item_id === undefined || event.summary_index === undefined)
754
776
  return [state, NO_EVENTS];
755
777
  const item = state.reasoningItems[event.item_id];
756
- if (!item)
778
+ if (!item?.open)
779
+ return [state, NO_EVENTS];
780
+ if (item.summaryParts[event.summary_index] !== "active")
757
781
  return [state, NO_EVENTS];
758
782
  return [
759
783
  {
@@ -803,17 +827,13 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
803
827
  return [state, NO_EVENTS];
804
828
  if (item.type === "message" && item.id !== undefined) {
805
829
  const itemPhase = messagePhase(item.phase);
806
- const phase = itemPhase === undefined ? state.messagePhases[item.id] : itemPhase;
830
+ const phase = itemPhase === undefined && state.message?.id === item.id ? state.message.phase : itemPhase;
807
831
  const events = [];
808
- const messageItems = new Set(state.messageItems);
809
- messageItems.delete(item.id);
810
- const { [item.id]: _phase, ...messagePhases } = state.messagePhases;
811
832
  return [
812
833
  {
813
834
  ...state,
814
835
  lifecycle: Lifecycle.textEnd(state.lifecycle, events, item.id, providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) })),
815
- messageItems,
816
- messagePhases,
836
+ message: state.message?.id === item.id ? undefined : state.message,
817
837
  },
818
838
  events,
819
839
  ];
@@ -821,19 +841,33 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
821
841
  if (item.type === "function_call") {
822
842
  if (!item.call_id || !item.name)
823
843
  return [state, NO_EVENTS];
824
- const id = item.id ?? item.call_id;
825
- const tools = state.tools[id]
844
+ const callID = item.call_id;
845
+ if (state.completedTools.has(callID))
846
+ return [state, NO_EVENTS];
847
+ const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined;
848
+ const fallback = item.id ?? callID;
849
+ // Match the pending tool by call id so item events that disagree on
850
+ // whether `item.id` is present still resolve the same call.
851
+ const registered = state.tools[fallback] !== undefined
852
+ ? fallback
853
+ : Object.keys(state.tools).find((key) => state.tools[key]?.id === callID);
854
+ const id = registered ?? fallback;
855
+ const tools = registered !== undefined
826
856
  ? state.tools
827
857
  : ToolStream.start(state.tools, id, {
828
- id: item.call_id,
858
+ id: callID,
829
859
  name: item.name,
830
- providerMetadata: item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined,
860
+ providerMetadata: metadata,
831
861
  });
832
862
  const result = item.arguments === undefined
833
863
  ? yield* ToolStream.finish(state.id, tools, id)
834
864
  : yield* ToolStream.finishWithInput(state.id, tools, id, item.arguments);
835
865
  const events = [];
836
- const resultEvents = result.events ?? [];
866
+ const finished = result.events ?? [];
867
+ // A done-only call never streamed a start event, so open its lifecycle here.
868
+ const resultEvents = registered !== undefined || finished.length === 0
869
+ ? finished
870
+ : [LLMEvent.toolInputStart({ id: callID, name: item.name, providerMetadata: metadata }), ...finished];
837
871
  const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle;
838
872
  events.push(...resultEvents);
839
873
  return [
@@ -843,6 +877,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
843
877
  hasFunctionCall: resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
844
878
  state.hasFunctionCall,
845
879
  tools: result.tools,
880
+ completedTools: new Set([...state.completedTools, callID]),
846
881
  },
847
882
  events,
848
883
  ];
@@ -852,17 +887,47 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
852
887
  const metadata = reasoningMetadata(state, item);
853
888
  const reasoningItem = state.reasoningItems[item.id];
854
889
  if (reasoningItem) {
890
+ if (!reasoningItem.open)
891
+ return [state, NO_EVENTS];
855
892
  const lifecycle = Object.entries(reasoningItem.summaryParts)
856
893
  .filter((entry) => entry[1] === "active" || entry[1] === "can-conclude")
857
894
  .reduce((lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, metadata), state.lifecycle);
858
- const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems;
859
- return [{ ...state, lifecycle, reasoningItems }, events];
895
+ return [
896
+ {
897
+ ...state,
898
+ lifecycle,
899
+ reasoningItems: {
900
+ ...state.reasoningItems,
901
+ [item.id]: {
902
+ ...reasoningItem,
903
+ open: false,
904
+ encryptedContent: item.encrypted_content ?? reasoningItem.encryptedContent,
905
+ },
906
+ },
907
+ },
908
+ events,
909
+ ];
860
910
  }
861
911
  if (!state.lifecycle.reasoning.has(item.id)) {
862
912
  const lifecycle = Lifecycle.stepStart(state.lifecycle, events);
863
913
  events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }));
864
914
  events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata }));
865
- return [{ ...state, lifecycle }, events];
915
+ return [
916
+ {
917
+ ...state,
918
+ lifecycle,
919
+ reasoningItems: {
920
+ ...state.reasoningItems,
921
+ [item.id]: {
922
+ open: false,
923
+ encryptedContent: item.encrypted_content,
924
+ summaryParts: { 0: "concluded" },
925
+ deltaIndexes: new Set(),
926
+ },
927
+ },
928
+ },
929
+ events,
930
+ ];
866
931
  }
867
932
  return [
868
933
  { ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata) },
@@ -877,7 +942,7 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
877
942
  const id = item.id ?? (item.type === "function_call" ? item.call_id : undefined);
878
943
  if (id === undefined ||
879
944
  ((item.type !== "function_call" || !current.tools[id]) &&
880
- (item.type !== "reasoning" || !current.reasoningItems[id])))
945
+ (item.type !== "reasoning" || !current.reasoningItems[id]?.open)))
881
946
  return Effect.succeed([current, events]);
882
947
  return onOutputItemDone(current, { type: "response.output_item.done", item }).pipe(Effect.map(([next, emitted]) => [next, [...events, ...emitted]]));
883
948
  })
@@ -979,6 +1044,11 @@ export const step = (state, input) => {
979
1044
  if (event.type === "response.output_item.added") {
980
1045
  if (event.item?.type === "message" && event.item.id === undefined)
981
1046
  return ProviderShared.eventError(state.id, `${event.type} message is missing id`);
1047
+ if (event.item &&
1048
+ isReasoningItem(event.item) &&
1049
+ state.reasoningItems[event.item.id] === undefined &&
1050
+ state.lifecycle.reasoning.size > 0)
1051
+ return ProviderShared.eventError(state.id, `${event.type} started reasoning before the previous item ended`);
982
1052
  const id = event.item?.id ?? (event.item?.type === "function_call" ? event.item.call_id : undefined);
983
1053
  return Effect.succeed(onOutputItemAdded(event.output_index !== undefined && id !== undefined
984
1054
  ? { ...state, outputItems: { ...state.outputItems, [event.output_index]: id } }
@@ -1014,10 +1084,10 @@ export const initial = (request, extension = BASE) => ({
1014
1084
  providerMetadataKey: request.model.route.providerMetadataKey ?? "openresponses",
1015
1085
  hasFunctionCall: false,
1016
1086
  tools: ToolStream.empty(),
1087
+ completedTools: new Set(),
1017
1088
  lifecycle: Lifecycle.initial(),
1018
1089
  outputItems: {},
1019
- messageItems: new Set(),
1020
- messagePhases: {},
1090
+ message: undefined,
1021
1091
  reasoningItems: {},
1022
1092
  });
1023
1093
  export const protocol = Protocol.make({
@@ -1,5 +1,6 @@
1
1
  import { Effect, Schema } from "effect";
2
2
  import { Route } from "../route/client.js";
3
+ import { Framing } from "../route/framing.js";
3
4
  import { HttpTransport } from "../route/transport/index.js";
4
5
  import { Protocol } from "../route/protocol.js";
5
6
  import { AIError, LLMEvent, Usage, type FinishReasonDetails, type CacheHint, type LLMRequest } from "../schema/index.js";
@@ -587,7 +588,7 @@ export declare const protocol: Protocol<{
587
588
  readonly tool_stream?: boolean | undefined;
588
589
  readonly frequency_penalty?: number | undefined;
589
590
  readonly presence_penalty?: number | undefined;
590
- }, string, {
591
+ }, string, "[DONE]" | {
591
592
  readonly [x: string]: unknown;
592
593
  readonly error?: {
593
594
  readonly [x: string]: unknown;
@@ -655,6 +656,7 @@ export declare const protocol: Protocol<{
655
656
  readonly native_finish_reason?: string | null | undefined;
656
657
  }[] | null | undefined;
657
658
  }, ParserState>;
659
+ export declare const framing: Framing.Definition<string>;
658
660
  export declare const httpTransport: HttpTransport.HttpJsonTransport<{
659
661
  readonly model: string;
660
662
  readonly messages: readonly ({
@@ -3,6 +3,7 @@ import { Tool } from "@opencode-ai/schema/tool";
3
3
  import { Route } from "../route/client.js";
4
4
  import { Auth } from "../route/auth.js";
5
5
  import { Endpoint } from "../route/endpoint.js";
6
+ import { Framing } from "../route/framing.js";
6
7
  import { HttpTransport } from "../route/transport/index.js";
7
8
  import { Protocol } from "../route/protocol.js";
8
9
  import { AIError, AIErrorReason, InvalidProviderOutputError, LLMEvent, ProviderInternalError, UnknownProviderError, Usage, } from "../schema/index.js";
@@ -176,6 +177,8 @@ export const OpenAIChatEvent = Schema.StructWithRest(Schema.Struct({
176
177
  usage: optionalNull(OpenAIChatUsage),
177
178
  error: optionalNull(OpenAIChatError),
178
179
  }), [Schema.Record(Schema.String, Schema.Unknown)]);
180
+ const DONE = "[DONE]";
181
+ const OpenAIChatStreamEvent = Schema.Union([Schema.Literal(DONE), Protocol.jsonEvent(OpenAIChatEvent)]);
179
182
  const lowerTool = (tool, inputSchema, options, supportsStrictMode) => ({
180
183
  type: "function",
181
184
  function: {
@@ -796,14 +799,13 @@ const step = (state, event) => Effect.gen(function* () {
796
799
  (Boolean(delta?.content) || Boolean(delta?.refusal) || toolDeltas.length > 0))
797
800
  lifecycle = Lifecycle.reasoningStart(lifecycle, events, "reasoning-0", deltaMetadata);
798
801
  const reasoningEmitted = state.reasoningEmitted || lifecycle.reasoning.has("reasoning-0");
799
- if (delta?.content) {
800
- lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0", reasoningMetadata(state.providerMetadataKey, reasoningField, reasoningDetailsObserved ? state.reasoningDetails : undefined));
802
+ // Reasoning is one response-wide channel: it stays open alongside text and
803
+ // refusal output so late reasoning deltas and details join the same block,
804
+ // and `finishEvents` closes it once with the complete metadata.
805
+ if (delta?.content)
801
806
  lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content);
802
- }
803
- if (delta?.refusal) {
804
- lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0", reasoningMetadata(state.providerMetadataKey, reasoningField, reasoningDetailsObserved ? state.reasoningDetails : undefined));
807
+ if (delta?.refusal)
805
808
  lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal);
806
- }
807
809
  // Compatible providers may omit indexes. Prefer durable identity, then use
808
810
  // batch position for parallel deltas or the latest call for sparse chunks.
809
811
  for (const [position, tool] of toolDeltas.entries()) {
@@ -891,7 +893,9 @@ const finishEvents = Effect.fn("OpenAIChat.finishEvents")(function* (state) {
891
893
  normalized: state.finishReason.normalized === "stop" && hasToolCalls ? "tool-calls" : state.finishReason.normalized,
892
894
  }
893
895
  : { normalized: hasToolCalls ? "tool-calls" : "stop" };
894
- const metadata = reasoningMetadata(state.providerMetadataKey, state.reasoningField, state.reasoningDetailsObserved ? state.reasoningDetails : undefined);
896
+ // Snapshot details at publish time so the emitted event never observes later
897
+ // mutation of the accumulated `reasoningDetails` array.
898
+ const metadata = reasoningMetadata(state.providerMetadataKey, state.reasoningField, state.reasoningDetailsObserved ? [...state.reasoningDetails] : undefined);
895
899
  const started = state.reasoningDetailsObserved && !state.reasoningEmitted
896
900
  ? Lifecycle.reasoningStart(state.lifecycle, events, "reasoning-0", reasoningMetadata(state.providerMetadataKey, state.reasoningField))
897
901
  : state.lifecycle;
@@ -917,7 +921,7 @@ export const protocol = Protocol.make({
917
921
  from: fromRequest,
918
922
  },
919
923
  stream: {
920
- event: Protocol.jsonEvent(OpenAIChatEvent),
924
+ event: OpenAIChatStreamEvent,
921
925
  initial: (request) => ({
922
926
  providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
923
927
  tools: ToolStream.empty(),
@@ -931,11 +935,13 @@ export const protocol = Protocol.make({
931
935
  nextToolIndex: 0,
932
936
  requireFinishReason: request.model.compatibility?.requireFinishReason ?? true,
933
937
  }),
934
- step,
938
+ step: (state, event) => (event === DONE ? Effect.succeed([state, []]) : step(state, event)),
939
+ terminal: (event) => event === DONE,
935
940
  onHalt: finishEvents,
936
941
  },
937
942
  });
938
- export const httpTransport = HttpTransport.sseJson.with();
943
+ export const framing = Framing.sseWithDone;
944
+ export const httpTransport = HttpTransport.sseJson.with().with({ framing });
939
945
  export const route = Route.make({
940
946
  id: ADAPTER,
941
947
  provider: "openai",
@@ -1,6 +1,5 @@
1
1
  import { Route } from "../route/client.js";
2
2
  import { Endpoint } from "../route/endpoint.js";
3
- import { Framing } from "../route/framing.js";
4
3
  import * as OpenAIChat from "./openai-chat.js";
5
4
  const ADAPTER = "openai-compatible-chat";
6
5
  /**
@@ -15,6 +14,6 @@ export const route = Route.make({
15
14
  providerMetadataKey: "openai",
16
15
  protocol: OpenAIChat.protocol,
17
16
  endpoint: Endpoint.path("/chat/completions"),
18
- framing: Framing.sse,
17
+ framing: OpenAIChat.framing,
19
18
  });
20
19
  export * as OpenAICompatibleChat from "./openai-compatible-chat.js";
@@ -117,13 +117,13 @@ export declare const toolResultText: (part: ToolResultPart) => string;
117
117
  export declare const errorText: (error: unknown) => string;
118
118
  /**
119
119
  * `framing` step for Server-Sent Events. Decodes UTF-8, runs the SSE channel
120
- * decoder, optionally filters named events, and drops empty / `[DONE]`
121
- * keep-alive events so the protocol event schema sees one JSON string per
122
- * element. Retry control events are ignored without interrupting the stream.
120
+ * decoder, optionally filters named events, and drops empty events. `[DONE]`
121
+ * is dropped by default or retained for protocols that use it as their stream
122
+ * boundary. Retry control events are ignored without interrupting the stream.
123
123
  * Decoder failures become provider output errors so the public error channel
124
124
  * stays `AIError`.
125
125
  */
126
- export declare const sseFraming: (bytes: Stream.Stream<Uint8Array, AIError>, events?: ReadonlySet<string>) => Stream.Stream<string, AIError>;
126
+ export declare const sseFraming: (bytes: Stream.Stream<Uint8Array, AIError>, events?: ReadonlySet<string>, includeDone?: boolean) => Stream.Stream<string, AIError>;
127
127
  /**
128
128
  * Canonical invalid-request constructor shared by protocol lowering.
129
129
  */