@opencode-ai/ai 0.0.0-dev-18539 → 0.0.0-dev-18545

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.
@@ -565,6 +565,7 @@ 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>>;
@@ -625,19 +625,57 @@ 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 || index === 0 || item.summaryParts[index] !== undefined)
631
+ return [state, NO_EVENTS];
632
+ if (Object.values(item.summaryParts).every((status) => status === "concluded"))
631
633
  return [state, NO_EVENTS];
632
- const index = event.summary_index ?? 0;
633
634
  const events = [];
635
+ const lifecycle = Object.entries(item.summaryParts)
636
+ .filter((entry) => entry[1] !== "concluded")
637
+ .reduce((lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${itemID}:${entry[0]}`, providerMetadata(state, { itemId: itemID })), state.lifecycle);
634
638
  return [
635
639
  {
636
640
  ...state,
637
- lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `${itemID}:${index}`, event.delta),
641
+ lifecycle: Lifecycle.reasoningStart(lifecycle, events, `${itemID}:${index}`, providerMetadata(state, { itemId: itemID, reasoningEncryptedContent: item.encryptedContent ?? null })),
638
642
  reasoningItems: {
639
643
  ...state.reasoningItems,
640
- [itemID]: { ...item, deltaIndexes: new Set([...item.deltaIndexes, index]) },
644
+ [itemID]: {
645
+ ...item,
646
+ summaryParts: {
647
+ ...Object.fromEntries(Object.entries(item.summaryParts).map((entry) => entry[1] === "concluded" ? entry : [entry[0], "concluded"])),
648
+ [index]: "active",
649
+ },
650
+ },
651
+ },
652
+ },
653
+ events,
654
+ ];
655
+ };
656
+ export const onReasoningDelta = (state, event, itemID) => {
657
+ const item = state.reasoningItems[itemID];
658
+ if (!event.delta || !item)
659
+ return [state, NO_EVENTS];
660
+ const index = event.summary_index ?? 0;
661
+ if (item.summaryParts[index] === "concluded")
662
+ return [state, NO_EVENTS];
663
+ // An unseen index cannot reopen an item whose parts have all concluded.
664
+ if (item.summaryParts[index] === undefined &&
665
+ Object.values(item.summaryParts).every((status) => status === "concluded"))
666
+ return [state, NO_EVENTS];
667
+ const started = item.summaryParts[index] === undefined ? startReasoningSummaryPart(state, itemID, index) : [state, NO_EVENTS];
668
+ const current = started[0].reasoningItems[itemID];
669
+ if (!current)
670
+ return started;
671
+ const events = [...started[1]];
672
+ return [
673
+ {
674
+ ...started[0],
675
+ lifecycle: Lifecycle.reasoningDelta(started[0].lifecycle, events, `${itemID}:${index}`, event.delta),
676
+ reasoningItems: {
677
+ ...started[0].reasoningItems,
678
+ [itemID]: { ...current, deltaIndexes: new Set([...current.deltaIndexes, index]) },
641
679
  },
642
680
  },
643
681
  events,
@@ -656,32 +694,45 @@ export const onReasoningDone = (state, event, itemID) => {
656
694
  return onReasoningDelta(state, { ...event, delta: event.text }, itemID);
657
695
  };
658
696
  const reasoningMetadata = (state, item) => providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null });
659
- // Responses APIs stream reasoning items in a stable order:
697
+ // Responses APIs normally stream reasoning items in this order:
660
698
  // `output_item.added` (reasoning) →
661
699
  // `reasoning_summary_part.added` (index=0) →
662
700
  // `reasoning_summary_text.delta` →
663
701
  // `reasoning_summary_part.done` (index=0) →
664
702
  // (repeat for index>0) →
665
703
  // `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.
704
+ // `onOutputItemAdded` seeds the per-item entry, while each later part start is
705
+ // also an implicit boundary for the previous part. This keeps the common event
706
+ // lifecycle ordered when a compatible provider omits or delays a part-done event.
671
707
  const onOutputItemAdded = (state, event) => {
672
708
  const item = event.item;
673
709
  if (item?.type === "message" && item.id !== undefined) {
710
+ const itemID = item.id;
674
711
  const phase = messagePhase(item.phase);
712
+ // A new message item is an implicit boundary for every earlier message
713
+ // item: text still streaming is ended, and all older items leave the
714
+ // tracked set so their late deltas stay no-ops instead of overlapping.
715
+ const events = [];
716
+ const lifecycle = [...state.lifecycle.text]
717
+ .filter((id) => id !== itemID)
718
+ .reduce((lifecycle, id) => {
719
+ const openPhase = state.messagePhases[id];
720
+ return Lifecycle.textEnd(lifecycle, events, id, providerMetadata(state, { itemId: id, ...(openPhase === undefined ? {} : { phase: openPhase }) }));
721
+ }, state.lifecycle);
722
+ const nextPhase = phase === undefined ? state.messagePhases[itemID] : phase;
675
723
  return [
676
724
  {
677
725
  ...state,
678
- messageItems: new Set([...state.messageItems, item.id]),
679
- messagePhases: phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase },
726
+ lifecycle,
727
+ messageItems: new Set([itemID]),
728
+ messagePhases: nextPhase === undefined ? {} : { [itemID]: nextPhase },
680
729
  },
681
- NO_EVENTS,
730
+ events,
682
731
  ];
683
732
  }
684
733
  if (item && isReasoningItem(item)) {
734
+ if (state.reasoningItems[item.id] !== undefined)
735
+ return [state, NO_EVENTS];
685
736
  const events = [];
686
737
  return [
687
738
  {
@@ -702,6 +753,10 @@ const onOutputItemAdded = (state, event) => {
702
753
  if (item?.type !== "function_call" || !item.call_id)
703
754
  return [state, NO_EVENTS];
704
755
  const id = item.id ?? item.call_id;
756
+ // Pending tools always store the call id, so this also catches duplicates
757
+ // that disagree on whether `item.id` is present.
758
+ if (Object.values(state.tools).some((tool) => tool?.id === item.call_id) || state.completedTools.has(item.call_id))
759
+ return [state, NO_EVENTS];
705
760
  const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined;
706
761
  const events = [];
707
762
  const lifecycle = Lifecycle.stepStart(state.lifecycle, events);
@@ -722,32 +777,7 @@ const onOutputItemAdded = (state, event) => {
722
777
  const onReasoningSummaryPartAdded = (state, event) => {
723
778
  if (event.item_id === undefined || event.summary_index === undefined)
724
779
  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
- ];
780
+ return startReasoningSummaryPart(state, event.item_id, event.summary_index);
751
781
  };
752
782
  const onReasoningSummaryPartDone = (state, event) => {
753
783
  if (event.item_id === undefined || event.summary_index === undefined)
@@ -755,6 +785,8 @@ const onReasoningSummaryPartDone = (state, event) => {
755
785
  const item = state.reasoningItems[event.item_id];
756
786
  if (!item)
757
787
  return [state, NO_EVENTS];
788
+ if (item.summaryParts[event.summary_index] !== "active")
789
+ return [state, NO_EVENTS];
758
790
  return [
759
791
  {
760
792
  ...state,
@@ -821,19 +853,33 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
821
853
  if (item.type === "function_call") {
822
854
  if (!item.call_id || !item.name)
823
855
  return [state, NO_EVENTS];
824
- const id = item.id ?? item.call_id;
825
- const tools = state.tools[id]
856
+ const callID = item.call_id;
857
+ if (state.completedTools.has(callID))
858
+ return [state, NO_EVENTS];
859
+ const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined;
860
+ const fallback = item.id ?? callID;
861
+ // Match the pending tool by call id so item events that disagree on
862
+ // whether `item.id` is present still resolve the same call.
863
+ const registered = state.tools[fallback] !== undefined
864
+ ? fallback
865
+ : Object.keys(state.tools).find((key) => state.tools[key]?.id === callID);
866
+ const id = registered ?? fallback;
867
+ const tools = registered !== undefined
826
868
  ? state.tools
827
869
  : ToolStream.start(state.tools, id, {
828
- id: item.call_id,
870
+ id: callID,
829
871
  name: item.name,
830
- providerMetadata: item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined,
872
+ providerMetadata: metadata,
831
873
  });
832
874
  const result = item.arguments === undefined
833
875
  ? yield* ToolStream.finish(state.id, tools, id)
834
876
  : yield* ToolStream.finishWithInput(state.id, tools, id, item.arguments);
835
877
  const events = [];
836
- const resultEvents = result.events ?? [];
878
+ const finished = result.events ?? [];
879
+ // A done-only call never streamed a start event, so open its lifecycle here.
880
+ const resultEvents = registered !== undefined || finished.length === 0
881
+ ? finished
882
+ : [LLMEvent.toolInputStart({ id: callID, name: item.name, providerMetadata: metadata }), ...finished];
837
883
  const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle;
838
884
  events.push(...resultEvents);
839
885
  return [
@@ -843,6 +889,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
843
889
  hasFunctionCall: resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
844
890
  state.hasFunctionCall,
845
891
  tools: result.tools,
892
+ completedTools: new Set([...state.completedTools, callID]),
846
893
  },
847
894
  events,
848
895
  ];
@@ -855,14 +902,43 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
855
902
  const lifecycle = Object.entries(reasoningItem.summaryParts)
856
903
  .filter((entry) => entry[1] === "active" || entry[1] === "can-conclude")
857
904
  .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];
905
+ // Keep the fully-concluded entry so duplicate or late events for this
906
+ // item remain no-ops instead of reopening lifecycle state.
907
+ return [
908
+ {
909
+ ...state,
910
+ lifecycle,
911
+ reasoningItems: {
912
+ ...state.reasoningItems,
913
+ [item.id]: {
914
+ ...reasoningItem,
915
+ encryptedContent: item.encrypted_content ?? reasoningItem.encryptedContent,
916
+ summaryParts: Object.fromEntries(Object.keys(reasoningItem.summaryParts).map((index) => [index, "concluded"])),
917
+ },
918
+ },
919
+ },
920
+ events,
921
+ ];
860
922
  }
861
923
  if (!state.lifecycle.reasoning.has(item.id)) {
862
924
  const lifecycle = Lifecycle.stepStart(state.lifecycle, events);
863
925
  events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }));
864
926
  events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata }));
865
- return [{ ...state, lifecycle }, events];
927
+ return [
928
+ {
929
+ ...state,
930
+ lifecycle,
931
+ reasoningItems: {
932
+ ...state.reasoningItems,
933
+ [item.id]: {
934
+ encryptedContent: item.encrypted_content,
935
+ summaryParts: { 0: "concluded" },
936
+ deltaIndexes: new Set(),
937
+ },
938
+ },
939
+ },
940
+ events,
941
+ ];
866
942
  }
867
943
  return [
868
944
  { ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata) },
@@ -979,6 +1055,11 @@ export const step = (state, input) => {
979
1055
  if (event.type === "response.output_item.added") {
980
1056
  if (event.item?.type === "message" && event.item.id === undefined)
981
1057
  return ProviderShared.eventError(state.id, `${event.type} message is missing id`);
1058
+ if (event.item &&
1059
+ isReasoningItem(event.item) &&
1060
+ state.reasoningItems[event.item.id] === undefined &&
1061
+ state.lifecycle.reasoning.size > 0)
1062
+ return ProviderShared.eventError(state.id, `${event.type} started reasoning before the previous item ended`);
982
1063
  const id = event.item?.id ?? (event.item?.type === "function_call" ? event.item.call_id : undefined);
983
1064
  return Effect.succeed(onOutputItemAdded(event.output_index !== undefined && id !== undefined
984
1065
  ? { ...state, outputItems: { ...state.outputItems, [event.output_index]: id } }
@@ -1014,6 +1095,7 @@ export const initial = (request, extension = BASE) => ({
1014
1095
  providerMetadataKey: request.model.route.providerMetadataKey ?? "openresponses",
1015
1096
  hasFunctionCall: false,
1016
1097
  tools: ToolStream.empty(),
1098
+ completedTools: new Set(),
1017
1099
  lifecycle: Lifecycle.initial(),
1018
1100
  outputItems: {},
1019
1101
  messageItems: new Set(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
- "version": "0.0.0-dev-18539",
3
+ "version": "0.0.0-dev-18545",
4
4
  "name": "@opencode-ai/ai",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,7 +30,7 @@
30
30
  "devDependencies": {
31
31
  "@clack/prompts": "1.0.0-alpha.1",
32
32
  "@effect/platform-node": "4.0.0-rc.112",
33
- "@opencode-ai/http-recorder": "0.0.0-dev-18539",
33
+ "@opencode-ai/http-recorder": "0.0.0-dev-18545",
34
34
  "@tsconfig/bun": "1.0.9",
35
35
  "@types/bun": "1.3.13",
36
36
  "@typescript/native-preview": "7.0.0-dev.20251207.1",
@@ -39,7 +39,7 @@
39
39
  "dependencies": {
40
40
  "@smithy/eventstream-codec": "4.2.14",
41
41
  "@smithy/util-utf8": "4.2.2",
42
- "@opencode-ai/schema": "0.0.0-dev-18539",
42
+ "@opencode-ai/schema": "0.0.0-dev-18545",
43
43
  "aws4fetch": "1.0.20",
44
44
  "effect": "4.0.0-rc.112",
45
45
  "google-auth-library": "10.5.0"