Parcourir la source

fix(llm): split OpenAI reasoning summary blocks (#29000)

Aiden Cline il y a 3 mois
Parent
commit
eb84f461b8

+ 217 - 21
packages/llm/src/protocols/openai-responses.ts

@@ -57,6 +57,11 @@ const OpenAIResponsesReasoningItem = Schema.Struct({
   encrypted_content: optionalNull(Schema.String),
 })
 
+const OpenAIResponsesItemReference = Schema.Struct({
+  type: Schema.tag("item_reference"),
+  id: Schema.String,
+})
+
 // `function_call_output.output` accepts either a plain string or an ordered
 // array of content items so tools can return images in addition to text.
 // https://platform.openai.com/docs/api-reference/responses/object
@@ -72,6 +77,7 @@ const OpenAIResponsesInputItem = Schema.Union([
   Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenAIResponsesInputContent) }),
   Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenAIResponsesOutputText) }),
   OpenAIResponsesReasoningItem,
+  OpenAIResponsesItemReference,
   Schema.Struct({
     type: Schema.tag("function_call"),
     call_id: Schema.String,
@@ -86,6 +92,15 @@ const OpenAIResponsesInputItem = Schema.Union([
 ])
 type OpenAIResponsesInputItem = Schema.Schema.Type<typeof OpenAIResponsesInputItem>
 
+// Mutable counterpart of the schema reasoning item so `lowerMessages` can fold
+// multiple streamed summary parts into the same item before flushing.
+type OpenAIResponsesReasoningInput = {
+  type: "reasoning"
+  id: string
+  summary: Array<{ type: "summary_text"; text: string }>
+  encrypted_content?: string | null
+}
+
 const OpenAIResponsesTool = Schema.Struct({
   type: Schema.tag("function"),
   name: Schema.String,
@@ -112,7 +127,7 @@ const OpenAIResponsesCoreFields = {
   tool_choice: Schema.optional(OpenAIResponsesToolChoice),
   store: Schema.optional(Schema.Boolean),
   prompt_cache_key: Schema.optional(Schema.String),
-  include: optionalArray(Schema.Literal("reasoning.encrypted_content")),
+  include: optionalArray(OpenAIOptions.OpenAIResponseIncludable),
   reasoning: Schema.optional(
     Schema.Struct({
       effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
@@ -193,6 +208,7 @@ const OpenAIResponsesEvent = Schema.Struct({
   type: Schema.String,
   delta: Schema.optional(Schema.String),
   item_id: Schema.optional(Schema.String),
+  summary_index: Schema.optional(Schema.Number),
   item: Schema.optional(OpenAIResponsesStreamItem),
   response: Schema.optional(
     Schema.StructWithRest(
@@ -216,6 +232,18 @@ interface ParserState {
   readonly tools: ToolStream.State<string>
   readonly hasFunctionCall: boolean
   readonly lifecycle: Lifecycle.State
+  readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
+  readonly store: boolean | undefined
+}
+
+type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
+
+interface ReasoningStreamItem {
+  readonly encryptedContent: string | null | undefined
+  // Keyed by OpenAI's numeric `summary_index`. JS object keys coerce to
+  // strings, but typing the map as `Record<number, ...>` documents intent
+  // and matches the wire field.
+  readonly summaryParts: Readonly<Record<number, ReasoningSummaryStatus>>
 }
 
 const invalid = ProviderShared.invalidRequest
@@ -245,22 +273,21 @@ const lowerToolCall = (part: ToolCallPart): OpenAIResponsesInputItem => ({
   arguments: ProviderShared.encodeJson(part.input),
 })
 
-const lowerReasoning = (part: ReasoningPart, store: boolean | undefined): OpenAIResponsesInputItem | undefined => {
+const lowerReasoning = (part: ReasoningPart): OpenAIResponsesReasoningInput | undefined => {
   const openai = part.providerMetadata?.openai
-  if (!ProviderShared.isRecord(openai) || typeof openai.itemId !== "string") return undefined
-  // With store:false, OpenAI only accepts previous reasoning items when the
-  // encrypted state is present. Bare rs_* ids point to non-persisted items.
-  if (store === false && typeof openai.reasoningEncryptedContent !== "string") return undefined
+  if (!ProviderShared.isRecord(openai) || typeof openai.itemId !== "string" || openai.itemId.length === 0)
+    return undefined
+  const encryptedContent =
+    typeof openai.reasoningEncryptedContent === "string"
+      ? openai.reasoningEncryptedContent
+      : openai.reasoningEncryptedContent === null
+        ? null
+        : undefined
   return {
     type: "reasoning",
     id: openai.itemId,
     summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
-    encrypted_content:
-      typeof openai.reasoningEncryptedContent === "string"
-        ? openai.reasoningEncryptedContent
-        : openai.reasoningEncryptedContent === null
-          ? null
-          : undefined,
+    encrypted_content: encryptedContent,
   }
 }
 
@@ -310,6 +337,8 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
 
     if (message.role === "assistant") {
       const content: TextPart[] = []
+      const reasoningItems: Record<string, OpenAIResponsesReasoningInput> = {}
+      const reasoningReferences = new Set<string>()
       const flushText = () => {
         if (content.length === 0) return
         input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) })
@@ -322,8 +351,21 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
         }
         if (part.type === "reasoning") {
           flushText()
-          const reasoning = lowerReasoning(part, store)
-          if (reasoning) input.push(reasoning)
+          const reasoning = lowerReasoning(part)
+          if (!reasoning) continue
+          if (store !== false && reasoning.id) {
+            if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
+            reasoningReferences.add(reasoning.id)
+            continue
+          }
+          const existing = reasoningItems[reasoning.id]
+          if (existing) {
+            existing.summary.push(...reasoning.summary)
+            if (typeof reasoning.encrypted_content === "string") existing.encrypted_content = reasoning.encrypted_content
+            continue
+          }
+          reasoningItems[reasoning.id] = reasoning
+          input.push(reasoning)
           continue
         }
         if (part.type === "tool-call") {
@@ -352,7 +394,14 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
     }
   }
 
-  return input
+  // With store:false, OpenAI only accepts previous reasoning items when the
+  // complete item has encrypted state. Summary blocks for one item may carry
+  // that state only on the last block, so filter after they have been joined.
+  return store === false
+    ? input.filter(
+        (item) => !("type" in item) || item.type !== "reasoning" || typeof item.encrypted_content === "string",
+      )
+    : input
 })
 
 const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (request: LLMRequest) {
@@ -362,14 +411,14 @@ const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (reques
   if (effort && !OpenAIOptions.isReasoningEffort(effort))
     return yield* invalid(`OpenAI Responses does not support reasoning effort ${effort}`)
   const summary = OpenAIOptions.reasoningSummary(request)
-  const encryptedState = OpenAIOptions.encryptedReasoning(request)
+  const include = OpenAIOptions.include(request)
   const verbosity = OpenAIOptions.textVerbosity(request)
   const instructions = OpenAIOptions.instructions(request)
   return {
     ...(instructions ? { instructions } : {}),
     ...(store !== undefined ? { store } : {}),
     ...(promptCacheKey ? { prompt_cache_key: promptCacheKey } : {}),
-    ...(encryptedState ? { include: ["reasoning.encrypted_content"] as const } : {}),
+    ...(include ? { include } : {}),
     ...(effort || summary ? { reasoning: { effort, summary } } : {}),
     ...(verbosity ? { text: { verbosity } } : {}),
   }
@@ -517,24 +566,53 @@ const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): Ste
 const onReasoningDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
   if (!event.delta) return [state, NO_EVENTS]
   const events: LLMEvent[] = []
+  const itemID = event.item_id ?? "reasoning-0"
+  const id =
+    event.summary_index !== undefined || state.reasoningItems[itemID]
+      ? `${itemID}:${event.summary_index ?? 0}`
+      : itemID
   return [
     {
       ...state,
-      lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, event.item_id ?? "reasoning-0", event.delta),
+      lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, id, event.delta),
     },
     events,
   ]
 }
 
-// The summary done event does not carry encrypted continuation state. Finish the
-// common reasoning block when the full reasoning item arrives in output_item.done.
 const onReasoningDone = (state: ParserState, _event: OpenAIResponsesEvent): StepResult => [state, NO_EVENTS]
 
 const reasoningMetadata = (item: OpenAIResponsesStreamItem & { id: string }) =>
   openaiMetadata({ itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
 
+// OpenAI Responses streams reasoning items in a stable order:
+//   `output_item.added` (reasoning) →
+//     `reasoning_summary_part.added` (index=0) →
+//     `reasoning_summary_text.delta` →
+//     `reasoning_summary_part.done` (index=0) →
+//     (repeat for index>0) →
+//   `output_item.done` (reasoning).
+// The handlers below rely on this ordering: `onOutputItemAdded` seeds the
+// per-item entry, `onReasoningSummaryPartAdded` for `summary_index === 0`
+// short-circuits when the entry already exists, and higher-index handlers
+// fold against the same entry. Behaviour for out-of-order events is
+// best-effort, not guaranteed.
 const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
   const item = event.item
+  if (item && isReasoningItem(item)) {
+    const events: LLMEvent[] = []
+    return [
+      {
+        ...state,
+        lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, `${item.id}:0`, reasoningMetadata(item)),
+        reasoningItems: {
+          ...state.reasoningItems,
+          [item.id]: { encryptedContent: item.encrypted_content, summaryParts: { 0: "active" } },
+        },
+      },
+      events,
+    ]
+  }
   if (item?.type !== "function_call" || !item.id) return [state, NO_EVENTS]
   const providerMetadata = openaiMetadata({ itemId: item.id })
   const events: LLMEvent[] = []
@@ -555,6 +633,103 @@ const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): Ste
   ]
 }
 
+const onReasoningSummaryPartAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
+  if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
+  const item = state.reasoningItems[event.item_id] ?? { encryptedContent: undefined, summaryParts: {} }
+  if (event.summary_index === 0) {
+    if (state.reasoningItems[event.item_id]) return [state, NO_EVENTS]
+    const events: LLMEvent[] = []
+    return [
+      {
+        ...state,
+        lifecycle: Lifecycle.reasoningStart(
+          state.lifecycle,
+          events,
+          `${event.item_id}:0`,
+          openaiMetadata({ itemId: event.item_id, reasoningEncryptedContent: null }),
+        ),
+        reasoningItems: {
+          ...state.reasoningItems,
+          [event.item_id]: { ...item, summaryParts: { 0: "active" } },
+        },
+      },
+      events,
+    ]
+  }
+
+  const events: LLMEvent[] = []
+  const closed = Object.entries(item.summaryParts)
+    .filter((entry) => entry[1] === "can-conclude")
+    .reduce(
+      (lifecycle, entry) =>
+        Lifecycle.reasoningEnd(
+          lifecycle,
+          events,
+          `${event.item_id}:${entry[0]}`,
+          openaiMetadata({ itemId: event.item_id }),
+        ),
+      state.lifecycle,
+    )
+  return [
+    {
+      ...state,
+      lifecycle: Lifecycle.reasoningStart(
+        closed,
+        events,
+        `${event.item_id}:${event.summary_index}`,
+        openaiMetadata({ itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null }),
+      ),
+      reasoningItems: {
+        ...state.reasoningItems,
+        [event.item_id]: {
+          ...item,
+          summaryParts: {
+            ...Object.fromEntries(
+              Object.entries(item.summaryParts).map((entry) =>
+                entry[1] === "can-conclude" ? [entry[0], "concluded" as const] : entry,
+              ),
+            ),
+            [event.summary_index]: "active",
+          },
+        },
+      },
+    },
+    events,
+  ]
+}
+
+const onReasoningSummaryPartDone = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
+  if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
+  const item = state.reasoningItems[event.item_id]
+  if (!item) return [state, NO_EVENTS]
+  const events: LLMEvent[] = []
+  return [
+    {
+      ...state,
+      lifecycle:
+        state.store !== false
+          ? Lifecycle.reasoningEnd(
+              state.lifecycle,
+              events,
+              `${event.item_id}:${event.summary_index}`,
+              openaiMetadata({ itemId: event.item_id }),
+            )
+          : state.lifecycle,
+      reasoningItems: {
+        ...state.reasoningItems,
+        [event.item_id]: {
+          ...item,
+          summaryParts: {
+            ...item.summaryParts,
+            [event.summary_index]: state.store !== false ? "concluded" : "can-conclude",
+          },
+        },
+      },
+    },
+    events,
+  ]
+}
+
 const onFunctionCallArgumentsDelta = Effect.fn("OpenAIResponses.onFunctionCallArgumentsDelta")(function* (
   state: ParserState,
   event: OpenAIResponsesEvent,
@@ -615,6 +790,17 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
   if (isReasoningItem(item)) {
     const events: LLMEvent[] = []
     const providerMetadata = reasoningMetadata(item)
+    const reasoningItem = state.reasoningItems[item.id]
+    if (reasoningItem) {
+      const lifecycle = Object.entries(reasoningItem.summaryParts)
+        .filter((entry) => entry[1] === "active" || entry[1] === "can-conclude")
+        .reduce(
+          (lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, providerMetadata),
+          state.lifecycle,
+        )
+      const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems
+      return [{ ...state, lifecycle, reasoningItems }, events] satisfies StepResult
+    }
     if (!state.lifecycle.reasoning.has(item.id)) {
       const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
       events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata }))
@@ -683,6 +869,10 @@ const step = (state: ParserState, event: OpenAIResponsesEvent) => {
     event.type === "response.reasoning_summary_text.done"
   )
     return Effect.succeed(onReasoningDone(state, event))
+  if (event.type === "response.reasoning_summary_part.added")
+    return Effect.succeed(onReasoningSummaryPartAdded(state, event))
+  if (event.type === "response.reasoning_summary_part.done")
+    return Effect.succeed(onReasoningSummaryPartDone(state, event))
   if (event.type === "response.output_item.added") return Effect.succeed(onOutputItemAdded(state, event))
   if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event)
   if (event.type === "response.output_item.done") return onOutputItemDone(state, event)
@@ -709,7 +899,13 @@ export const protocol = Protocol.make({
   },
   stream: {
     event: Protocol.jsonEvent(OpenAIResponsesEvent),
-    initial: () => ({ hasFunctionCall: false, tools: ToolStream.empty<string>(), lifecycle: Lifecycle.initial() }),
+    initial: (request) => ({
+      hasFunctionCall: false,
+      tools: ToolStream.empty<string>(),
+      lifecycle: Lifecycle.initial(),
+      reasoningItems: {},
+      store: OpenAIOptions.store(request),
+    }),
     step,
     terminal: (event) => TERMINAL_TYPES.has(event.type),
   },

+ 14 - 6
packages/llm/src/protocols/utils/lifecycle.ts

@@ -24,16 +24,24 @@ export const textDelta = (state: State, events: LLMEvent[], id: string, text: st
   return { ...stepped, text: new Set([...stepped.text, id]) }
 }
 
-export const reasoningDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
+export const reasoningStart = (
+  state: State,
+  events: LLMEvent[],
+  id: string,
+  providerMetadata?: ProviderMetadata,
+): State => {
+  if (state.reasoning.has(id)) return state
   const stepped = stepStart(state, events)
-  if (stepped.reasoning.has(id)) {
-    events.push(LLMEvent.reasoningDelta({ id, text }))
-    return stepped
-  }
-  events.push(LLMEvent.reasoningStart({ id }), LLMEvent.reasoningDelta({ id, text }))
+  events.push(LLMEvent.reasoningStart({ id, providerMetadata }))
   return { ...stepped, reasoning: new Set([...stepped.reasoning, id]) }
 }
 
+export const reasoningDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
+  const started = reasoningStart(state, events, id)
+  events.push(LLMEvent.reasoningDelta({ id, text }))
+  return started
+}
+
 export const reasoningEnd = (
   state: State,
   events: LLMEvent[],

+ 29 - 5
packages/llm/src/protocols/utils/openai-options.ts

@@ -7,12 +7,28 @@ export const OpenAIReasoningEfforts = ReasoningEfforts.filter(
 )
 export type OpenAIReasoningEffort = (typeof OpenAIReasoningEfforts)[number]
 
+// Mirrors OpenAI's `ResponseIncludable` union from the official SDK. Keep this
+// in lockstep with `openai-node/src/resources/responses/responses.ts`.
+export const OpenAIResponseIncludables = [
+  "file_search_call.results",
+  "web_search_call.results",
+  "web_search_call.action.sources",
+  "message.input_image.image_url",
+  "computer_call_output.output.image_url",
+  "code_interpreter_call.outputs",
+  "reasoning.encrypted_content",
+  "message.output_text.logprobs",
+] as const
+export type OpenAIResponseIncludable = (typeof OpenAIResponseIncludables)[number]
+
 const REASONING_EFFORTS = new Set<string>(ReasoningEfforts)
 const OPENAI_REASONING_EFFORTS = new Set<string>(OpenAIReasoningEfforts)
 const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
+const INCLUDABLES = new Set<string>(OpenAIResponseIncludables)
 
 export const OpenAIReasoningEffort = Schema.Literals(OpenAIReasoningEfforts)
 export const OpenAITextVerbosity = TextVerbosity
+export const OpenAIResponseIncludable = Schema.Literals(OpenAIResponseIncludables)
 
 const isAnyReasoningEffort = (effort: unknown): effort is ReasoningEffort =>
   typeof effort === "string" && REASONING_EFFORTS.has(effort)
@@ -35,12 +51,20 @@ export const reasoningEffort = (request: LLMRequest): ReasoningEffort | undefine
   return isAnyReasoningEffort(value) ? value : undefined
 }
 
-export const reasoningSummary = (request: LLMRequest): "auto" | undefined => {
-  return options(request)?.reasoningSummary === "auto" ? "auto" : undefined
-}
+export const reasoningSummary = (request: LLMRequest): "auto" | undefined =>
+  options(request)?.reasoningSummary === "auto" ? "auto" : undefined
 
-export const encryptedReasoning = (request: LLMRequest) =>
-  options(request)?.includeEncryptedReasoning === true ? true : undefined
+// Resolve the OpenAI Responses `include` field. Filters out unknown
+// includable values defensively so a typo in upstream config drops the
+// invalid entry instead of poisoning the wire body. An empty array (either
+// passed directly or produced by filtering) is treated as "no include" and
+// returns undefined so the request body omits the field entirely.
+export const include = (request: LLMRequest): ReadonlyArray<OpenAIResponseIncludable> | undefined => {
+  const value = options(request)?.include
+  if (!Array.isArray(value)) return undefined
+  const filtered = value.filter((entry): entry is OpenAIResponseIncludable => INCLUDABLES.has(entry))
+  return filtered.length > 0 ? filtered : undefined
+}
 
 export const promptCacheKey = (request: LLMRequest) => {
   const value = options(request)?.promptCacheKey

+ 14 - 2
packages/llm/src/providers/openai-options.ts

@@ -1,5 +1,8 @@
 import type { ProviderOptions, ReasoningEffort, TextVerbosity } from "../schema"
 import { mergeProviderOptions } from "../schema"
+import type { OpenAIResponseIncludable } from "../protocols/utils/openai-options"
+
+export type { OpenAIResponseIncludable } from "../protocols/utils/openai-options"
 
 export interface OpenAIOptionsInput {
   readonly [key: string]: unknown
@@ -7,7 +10,10 @@ export interface OpenAIOptionsInput {
   readonly promptCacheKey?: string
   readonly reasoningEffort?: ReasoningEffort
   readonly reasoningSummary?: "auto"
-  readonly includeEncryptedReasoning?: boolean
+  // OpenAI Responses `include` wire field. Mirrors the official SDK's
+  // `ResponseIncludable[]` union exactly so AI SDK callers and direct
+  // native-SDK callers share one shape and no translation is required.
+  readonly include?: ReadonlyArray<OpenAIResponseIncludable>
   readonly textVerbosity?: TextVerbosity
 }
 
@@ -25,7 +31,7 @@ const openAIProviderOptions = (options: OpenAIOptionsInput | undefined): Provide
       promptCacheKey: options?.promptCacheKey,
       reasoningEffort: options?.reasoningEffort,
       reasoningSummary: options?.reasoningSummary,
-      includeEncryptedReasoning: options?.includeEncryptedReasoning,
+      include: options?.include,
       textVerbosity: options?.textVerbosity,
     }),
   )
@@ -42,6 +48,12 @@ export const gpt5DefaultOptions = (
   return openAIProviderOptions({
     reasoningEffort: "medium",
     reasoningSummary: "auto",
+    // GPT-5 reasoning models are configured stateless (`store: false`) by
+    // `openAIDefaultOptions` below, so the only way a follow-up turn can
+    // carry reasoning state is via the encrypted reasoning include. Without
+    // this, callers using the default model facade get reasoning summaries
+    // they cannot replay statelessly.
+    include: ["reasoning.encrypted_content"],
     textVerbosity:
       options.textVerbosity === true && id.includes("gpt-5.") && !id.includes("codex") && !id.includes("-chat")
         ? "low"

+ 1 - 1
packages/llm/src/providers/openai.ts

@@ -5,7 +5,7 @@ import * as OpenAIChat from "../protocols/openai-chat"
 import * as OpenAIResponses from "../protocols/openai-responses"
 import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
 
-export type { OpenAIOptionsInput } from "./openai-options"
+export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options"
 
 export const id = ProviderID.make("openai")
 

+ 1 - 1
packages/llm/src/route/client.ts

@@ -283,7 +283,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
           )
         return events.pipe(
           Stream.mapAccumEffect(
-            protocol.stream.initial,
+            () => protocol.stream.initial(request),
             protocol.stream.step,
             protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined,
           ),

+ 2 - 2
packages/llm/src/route/protocol.ts

@@ -52,8 +52,8 @@ export interface ProtocolBody<Body> {
 export interface ProtocolStream<Frame, Event, State> {
   /** Schema for one decoded streaming event, decoded from a transport frame. */
   readonly event: Schema.Codec<Event, Frame>
-  /** Initial parser state. Called once per response. */
-  readonly initial: () => State
+  /** Initial parser state. Called once per response with the resolved request. */
+  readonly initial: (request: LLMRequest) => State
   /** Translate one event into emitted `LLMEvent`s plus the next state. */
   readonly step: (state: State, event: Event) => Effect.Effect<readonly [State, ReadonlyArray<LLMEvent>], LLMError>
   /** Optional request-completion signal for transports that do not end naturally. */

+ 1 - 1
packages/llm/test/continuation-scenarios.ts

@@ -97,7 +97,7 @@ export function continuationRequest(input: {
     tools: features.has("tool-call") ? [continuationTool] : [],
     cache: "none",
     providerOptions: features.has("encrypted-reasoning")
-      ? { openai: { store: false, includeEncryptedReasoning: true, reasoningSummary: "auto" } }
+      ? { openai: { store: false, include: ["reasoning.encrypted_content"], reasoningSummary: "auto" } }
       : undefined,
     generation: { maxTokens: 80, temperature: 0 },
   })

Fichier diff supprimé car celui-ci est trop grand
+ 1 - 1
packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json


Fichier diff supprimé car celui-ci est trop grand
+ 1 - 1
packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json


Fichier diff supprimé car celui-ci est trop grand
+ 9 - 3
packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning.json


+ 253 - 1
packages/llm/test/provider/openai-responses.test.ts

@@ -393,7 +393,7 @@ describe("OpenAI Responses route", () => {
               promptCacheKey: "session_123",
               reasoningEffort: "high",
               reasoningSummary: "auto",
-              includeEncryptedReasoning: true,
+              include: ["reasoning.encrypted_content"],
             },
           },
         }),
@@ -407,6 +407,108 @@ describe("OpenAI Responses route", () => {
     }),
   )
 
+  it.effect("accepts the full ResponseIncludable union", () =>
+    Effect.gen(function* () {
+      const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
+        LLM.request({
+          model,
+          prompt: "hi",
+          providerOptions: {
+            openai: {
+              include: ["reasoning.encrypted_content", "code_interpreter_call.outputs", "web_search_call.results"],
+            },
+          },
+        }),
+      )
+
+      expect(prepared.body.include).toEqual([
+        "reasoning.encrypted_content",
+        "code_interpreter_call.outputs",
+        "web_search_call.results",
+      ])
+    }),
+  )
+
+  it.effect("filters unknown includable values out of the include array", () =>
+    Effect.gen(function* () {
+      const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
+        LLM.request({
+          model,
+          prompt: "hi",
+          // The user passed one invalid entry alongside a valid one. Keep the
+          // valid one so the request still succeeds rather than failing on a
+          // typo from upstream config.
+          providerOptions: { openai: { include: ["reasoning.encrypted_content", "bogus.thing"] } },
+        }),
+      )
+
+      expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
+    }),
+  )
+
+  it.effect("treats an explicit empty include as no include at all", () =>
+    Effect.gen(function* () {
+      const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
+        LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: [] } } }),
+      )
+
+      expect(prepared.body.include).toBeUndefined()
+    }),
+  )
+
+  it.effect("treats an all-invalid include as no include at all", () =>
+    Effect.gen(function* () {
+      const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
+        LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: ["bogus.thing"] } } }),
+      )
+
+      expect(prepared.body.include).toBeUndefined()
+    }),
+  )
+
+  it.effect("omits include when no include is set", () =>
+    Effect.gen(function* () {
+      const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
+        LLM.request({ model, prompt: "hi", providerOptions: { openai: { store: false } } }),
+      )
+
+      expect(prepared.body.include).toBeUndefined()
+    }),
+  )
+
+  it.effect("requests encrypted reasoning by default for GPT-5 reasoning models", () =>
+    Effect.gen(function* () {
+      // The native OpenAI facade configures GPT-5 stateless (store: false) with
+      // reasoningSummary: "auto" by default. Without `include`, a follow-up
+      // turn cannot replay reasoning state, so the facade also opts into
+      // `reasoning.encrypted_content` automatically.
+      const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
+        LLM.request({
+          model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"),
+          prompt: "hi",
+        }),
+      )
+
+      expect(prepared.body.store).toBe(false)
+      expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
+      expect(prepared.body.reasoning).toEqual({ effort: "medium", summary: "auto" })
+    }),
+  )
+
+  it.effect("lets callers opt out of the GPT-5 default include", () =>
+    Effect.gen(function* () {
+      const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
+        LLM.request({
+          model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"),
+          prompt: "hi",
+          providerOptions: { openai: { include: [] } },
+        }),
+      )
+
+      expect(prepared.body.include).toBeUndefined()
+    }),
+  )
+
   it.effect("request OpenAI provider options override route defaults", () =>
     Effect.gen(function* () {
       const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
@@ -547,6 +649,94 @@ describe("OpenAI Responses route", () => {
     }),
   )
 
+  it.effect("streams each reasoning summary part as a separate block", () =>
+    Effect.gen(function* () {
+      const response = yield* LLMClient.generate(
+        LLM.updateRequest(request, { providerOptions: { openai: { store: false } } }),
+      ).pipe(
+        Effect.provide(
+          fixedResponse(
+            sseEvents(
+              {
+                type: "response.output_item.added",
+                item: { type: "reasoning", id: "rs_1", encrypted_content: null },
+              },
+              { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
+              { type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
+              { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
+              { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
+              { type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "Second" },
+              { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 },
+              {
+                type: "response.output_item.done",
+                item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
+              },
+              { type: "response.completed", response: { id: "resp_1" } },
+            ),
+          ),
+        ),
+      )
+
+      expect(response.reasoning).toBe("FirstSecond")
+      expect(response.events).toMatchObject([
+        { type: "step-start", index: 0 },
+        {
+          type: "reasoning-start",
+          id: "rs_1:0",
+          providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
+        },
+        { type: "reasoning-delta", id: "rs_1:0", text: "First" },
+        { type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
+        {
+          type: "reasoning-start",
+          id: "rs_1:1",
+          providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
+        },
+        { type: "reasoning-delta", id: "rs_1:1", text: "Second" },
+        {
+          type: "reasoning-end",
+          id: "rs_1:1",
+          providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
+        },
+        { type: "step-finish", index: 0, reason: "stop" },
+        { type: "finish", reason: "stop" },
+      ])
+    }),
+  )
+
+  it.effect("closes reasoning summary parts when storage is not disabled", () =>
+    Effect.gen(function* () {
+      const response = yield* LLMClient.generate(request).pipe(
+        Effect.provide(
+          fixedResponse(
+            sseEvents(
+              {
+                type: "response.output_item.added",
+                item: { type: "reasoning", id: "rs_1", encrypted_content: null },
+              },
+              { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
+              { type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
+              { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
+              { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
+              { type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "Second" },
+              { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 },
+              {
+                type: "response.output_item.done",
+                item: { type: "reasoning", id: "rs_1", encrypted_content: null },
+              },
+              { type: "response.completed", response: { id: "resp_1" } },
+            ),
+          ),
+        ),
+      )
+
+      expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
+        { type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
+        { type: "reasoning-end", id: "rs_1:1", providerMetadata: { openai: { itemId: "rs_1" } } },
+      ])
+    }),
+  )
+
   it.effect("continues a stateless reasoning conversation", () =>
     Effect.gen(function* () {
       const response = yield* LLMClient.generate(
@@ -570,6 +760,7 @@ describe("OpenAI Responses route", () => {
             ]),
             Message.user("Summarize it."),
           ],
+          providerOptions: { openai: { store: false } },
         }),
       ).pipe(
         Effect.provide(
@@ -627,6 +818,7 @@ describe("OpenAI Responses route", () => {
               { type: "text", text: "After." },
             ]),
           ],
+          providerOptions: { openai: { store: false } },
         }),
       )
 
@@ -643,6 +835,66 @@ describe("OpenAI Responses route", () => {
     }),
   )
 
+  it.effect("references stored reasoning items by id", () =>
+    Effect.gen(function* () {
+      const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
+        LLM.request({
+          model,
+          messages: [
+            Message.assistant([
+              {
+                type: "reasoning",
+                text: "Checked the previous diff.",
+                providerMetadata: { openai: { itemId: "rs_1" } },
+              },
+            ]),
+          ],
+          providerOptions: { openai: { store: true } },
+        }),
+      )
+
+      expect(prepared.body.input).toEqual([{ type: "item_reference", id: "rs_1" }])
+    }),
+  )
+
+  it.effect("joins streamed summary blocks into one continuation reasoning item", () =>
+    Effect.gen(function* () {
+      const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
+        LLM.request({
+          id: "req_multi_summary_continuation",
+          model,
+          messages: [
+            Message.assistant([
+              {
+                type: "reasoning",
+                text: "First",
+                providerMetadata: { openai: { itemId: "rs_1" } },
+              },
+              {
+                type: "reasoning",
+                text: "Second",
+                providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
+              },
+            ]),
+          ],
+          providerOptions: { openai: { store: false } },
+        }),
+      )
+
+      expect(prepared.body.input).toEqual([
+        {
+          type: "reasoning",
+          id: "rs_1",
+          encrypted_content: "encrypted-state",
+          summary: [
+            { type: "summary_text", text: "First" },
+            { type: "summary_text", text: "Second" },
+          ],
+        },
+      ])
+    }),
+  )
+
   it.effect("skips non-persisted reasoning ids without encrypted state", () =>
     Effect.gen(function* () {
       const prepared = yield* LLMClient.prepare(

+ 1 - 1
packages/llm/test/recorded-scenarios.ts

@@ -158,7 +158,7 @@ const normalizeImageText = (value: string) =>
 const encryptedReasoningOptions = {
   openai: {
     store: false,
-    includeEncryptedReasoning: true,
+    include: ["reasoning.encrypted_content"],
     reasoningEffort: "low",
     reasoningSummary: "auto",
   },

+ 66 - 0
packages/llm/test/tool-runtime.test.ts

@@ -4,6 +4,7 @@ import { GenerationOptions, LLM, LLMEvent, LLMRequest, LLMResponse, ToolChoice }
 import { Auth, LLMClient } from "../src/route"
 import * as AnthropicMessages from "../src/protocols/anthropic-messages"
 import * as OpenAIChat from "../src/protocols/openai-chat"
+import * as OpenAIResponses from "../src/protocols/openai-responses"
 import { tool, ToolFailure, type ToolExecuteContext } from "../src/tool"
 import { ToolRuntime } from "../src/tool-runtime"
 import { it } from "./lib/effect"
@@ -309,6 +310,71 @@ describe("LLMClient tools", () => {
     }),
   )
 
+  it.effect("replays encrypted OpenAI reasoning items with tool outputs", () =>
+    Effect.gen(function* () {
+      const bodies: unknown[] = []
+      const layer = dynamicResponse((input) =>
+        Effect.sync(() => {
+          bodies.push(decodeJson(input.text))
+          return input.respond(
+            bodies.length === 1
+              ? sseEvents(
+                  { type: "response.output_item.added", item: { type: "reasoning", id: "rs_1", encrypted_content: null } },
+                  { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
+                  { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
+                  {
+                    type: "response.output_item.done",
+                    item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
+                  },
+                  {
+                    type: "response.output_item.added",
+                    item: { type: "function_call", id: "item_1", call_id: "call_1", name: "get_weather", arguments: "" },
+                  },
+                  { type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"city":"Paris"}' },
+                  {
+                    type: "response.output_item.done",
+                    item: {
+                      type: "function_call",
+                      id: "item_1",
+                      call_id: "call_1",
+                      name: "get_weather",
+                      arguments: '{"city":"Paris"}',
+                    },
+                  },
+                  { type: "response.completed", response: {} },
+                )
+              : sseEvents(
+                  { type: "response.output_text.delta", item_id: "msg_1", delta: "Done." },
+                  { type: "response.completed", response: {} },
+                ),
+            { headers: { "content-type": "text/event-stream" } },
+          )
+        }),
+      )
+
+      yield* TestToolRuntime.runTools({
+        request: LLM.request({
+          model: OpenAIResponses.route
+            .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
+            .model({ id: "gpt-5.5" }),
+          prompt: "Use the tool.",
+          providerOptions: { openai: { store: false, include: ["reasoning.encrypted_content"] } },
+        }),
+        tools: { get_weather },
+      }).pipe(Stream.runCollect, Effect.provide(layer))
+
+      expect(bodies[1]).toMatchObject({
+        include: ["reasoning.encrypted_content"],
+        input: [
+          { role: "user" },
+          { type: "reasoning", id: "rs_1", summary: [], encrypted_content: "encrypted-state" },
+          { type: "function_call", call_id: "call_1", name: "get_weather" },
+          { type: "function_call_output", call_id: "call_1" },
+        ],
+      })
+    }),
+  )
+
   it.effect("emits tool-error for unknown tools so the model can self-correct", () =>
     Effect.gen(function* () {
       const layer = scriptedResponses([

+ 12 - 4
packages/opencode/src/provider/transform.ts

@@ -17,6 +17,11 @@ function mimeToModality(mime: string): Modality | undefined {
 
 export const OUTPUT_TOKEN_MAX = 32_000
 
+// OpenAI Responses `include` value that returns the encrypted reasoning state
+// needed for stateless multi-turn reasoning (store: false). Hoisted so every
+// branch that requests it stays in lockstep.
+const INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"] as const
+
 export function sanitizeSurrogates(content: string) {
   return content.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "\uFFFD")
 }
@@ -756,7 +761,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
           {
             reasoningEffort: effort,
             reasoningSummary: "auto",
-            include: ["reasoning.encrypted_content"],
+            include: INCLUDE_ENCRYPTED_REASONING,
           },
         ]),
       )
@@ -790,7 +795,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
           {
             reasoningEffort: effort,
             reasoningSummary: "auto",
-            include: ["reasoning.encrypted_content"],
+            include: INCLUDE_ENCRYPTED_REASONING,
           },
         ]),
       )
@@ -803,7 +808,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
           {
             reasoningEffort: effort,
             reasoningSummary: "auto",
-            include: ["reasoning.encrypted_content"],
+            include: INCLUDE_ENCRYPTED_REASONING,
           },
         ]),
       )
@@ -1134,6 +1139,9 @@ export function options(input: {
     if (!input.model.api.id.includes("gpt-5-pro")) {
       result["reasoningEffort"] = "medium"
       result["reasoningSummary"] = "auto"
+      if (input.model.api.npm === "@ai-sdk/openai") {
+        result["include"] = INCLUDE_ENCRYPTED_REASONING
+      }
     }
 
     // Only set textVerbosity for non-chat gpt-5.x models
@@ -1149,7 +1157,7 @@ export function options(input: {
 
     if (input.model.providerID.startsWith("opencode")) {
       result["promptCacheKey"] = input.sessionID
-      result["include"] = ["reasoning.encrypted_content"]
+      result["include"] = INCLUDE_ENCRYPTED_REASONING
       result["reasoningSummary"] = "auto"
     }
   }

+ 8 - 0
packages/opencode/src/session/llm/native-runtime.ts

@@ -70,6 +70,14 @@ export function stream(input: StreamInput): StreamResult {
 
   // Integration point with @opencode-ai/llm: native-request lowers session data
   // into an LLMRequest, then LLMClient handles route selection and transport.
+  //
+  // ProviderTransform.providerOptions builds AI-SDK-shaped options for the
+  // selected SDK key (e.g. "openai") and the native LLM SDK reads the same
+  // keys via OpenAIOptions.* (store, reasoningEffort, reasoningSummary,
+  // include, textVerbosity, promptCacheKey). Both sides intentionally use
+  // OpenAI's official wire field names, so this is identity, not translation
+  // — if a field ever needs to differ between the two surfaces, the
+  // translation belongs here, not split across both packages.
   const stream = input.llmClient.stream({
     request: LLMNative.request({
       model: input.model,

Fichier diff supprimé car celui-ci est trop grand
+ 8 - 3
packages/opencode/test/fixtures/recordings/session/native-openai-oauth-tool-loop.json


Fichier diff supprimé car celui-ci est trop grand
+ 8 - 3
packages/opencode/test/fixtures/recordings/session/native-zen-tool-loop.json


+ 1 - 0
packages/opencode/test/provider/transform.test.ts

@@ -271,6 +271,7 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => {
     const model = createGpt5Model("gpt-5.2")
     const result = ProviderTransform.options({ model, sessionID, providerOptions: {} })
     expect(result.textVerbosity).toBe("low")
+    expect(result.include).toEqual(["reasoning.encrypted_content"])
   })
 
   test("gpt-5.1 should have textVerbosity set to low", () => {

+ 17 - 2
packages/opencode/test/session/llm-native-recorded.test.ts

@@ -336,10 +336,25 @@ const weatherTool = tool({
 })
 
 const toolRoundtrip = (
+  events: ReadonlyArray<LLMEvent>,
   call: { readonly id: string; readonly name: string; readonly input: unknown },
   result: JSONValue,
 ): ModelMessage[] => [
-  { role: "assistant", content: [{ type: "tool-call", toolCallId: call.id, toolName: call.name, input: call.input }] },
+  {
+    role: "assistant",
+    content: [
+      ...events.filter(LLMEvent.is.reasoningEnd).map((part) => ({
+        type: "reasoning" as const,
+        text: events
+          .filter(LLMEvent.is.reasoningDelta)
+          .filter((event) => event.id === part.id)
+          .map((event) => event.text)
+          .join(""),
+        providerMetadata: part.providerMetadata,
+      })),
+      { type: "tool-call", toolCallId: call.id, toolName: call.name, input: call.input },
+    ],
+  },
   {
     role: "tool",
     content: [
@@ -395,7 +410,7 @@ const driveToolLoop = (scenario: RecordedScenario) =>
 
     const turn2 = yield* collect({
       ...base,
-      messages: [userMessage, ...toolRoundtrip(toolCall!, WEATHER_RESULT)],
+      messages: [userMessage, ...toolRoundtrip(turn1, toolCall!, WEATHER_RESULT)],
     })
 
     expect(LLMResponse.text({ events: turn2 })).toMatch(/Paris is sunny/i)

+ 40 - 1
packages/opencode/test/session/llm-native.test.ts

@@ -591,7 +591,7 @@ describe("session.llm-native.request", () => {
         ]),
         storedSession.user("Summarize it."),
       ],
-      providerOptions: { openai: { store: false, includeEncryptedReasoning: true } },
+      providerOptions: { openai: { store: false, include: ["reasoning.encrypted_content"] } },
       expectedBody: {
         input: [
           openAIResponses.user("What changed?"),
@@ -608,6 +608,45 @@ describe("session.llm-native.request", () => {
     }),
   )
 
+  it.effect("preserves empty encrypted OpenAI reasoning items before tool output", () =>
+    expectOpenAIResponsesRequest({
+      history: [
+        storedSession.assistant([
+          storedSession.openaiReasoning("", {
+            storedAs: "providerMetadata",
+            itemId: "rs_1",
+            encryptedContent: "encrypted-state",
+          }),
+        ]),
+      ],
+      providerOptions: { openai: { store: false, include: ["reasoning.encrypted_content"] } },
+      expectedBody: {
+        input: [{ type: "reasoning", id: "rs_1", summary: [], encrypted_content: "encrypted-state" }],
+        include: ["reasoning.encrypted_content"],
+        store: false,
+      },
+    }),
+  )
+
+  it.effect("references stored OpenAI reasoning items by id", () =>
+    expectOpenAIResponsesRequest({
+      history: [
+        storedSession.assistant([
+          storedSession.openaiReasoning("Checked the previous diff.", {
+            storedAs: "providerMetadata",
+            itemId: "rs_1",
+            encryptedContent: null,
+          }),
+        ]),
+      ],
+      providerOptions: { openai: { store: true } },
+      expectedBody: {
+        input: [{ type: "item_reference", id: "rs_1" }],
+        store: true,
+      },
+    }),
+  )
+
   it.effect("uses provider fetch override for native OpenAI OAuth requests", () =>
     Effect.gen(function* () {
       const captures: Array<{ url: string; body: unknown }> = []

+ 1 - 0
packages/opencode/test/session/llm.test.ts

@@ -1166,6 +1166,7 @@ describe("session.llm.stream", () => {
         expect(capture.body.model).toBe(model.id)
         expect(capture.body.stream).toBe(true)
         expect((capture.body.reasoning as { effort?: string } | undefined)?.effort).toBe("high")
+        expect(capture.body.include).toEqual(["reasoning.encrypted_content"])
         expect(JSON.stringify(capture.body.input)).toContain("You are a helpful assistant.")
         expect(capture.body.input).toContainEqual({ role: "user", content: [{ type: "input_text", text: "Hello" }] })
       }),

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff