Преглед изворни кода

fix(ai): preserve Responses item IDs

Aiden Cline пре 1 недеља
родитељ
комит
a619814c79

+ 106 - 54
packages/ai/src/protocols/open-responses.ts

@@ -70,10 +70,14 @@ const OpenResponsesReasoningItem = Schema.Struct({
   encrypted_content: optionalNull(Schema.String),
 })
 
-const OpenResponsesItemReference = Schema.Struct({
-  type: Schema.tag("item_reference"),
-  id: Schema.String,
-})
+export const ProviderItem = Schema.StructWithRest(
+  Schema.Struct({
+    type: Schema.String,
+    id: Schema.String,
+  }),
+  [Schema.Record(Schema.String, Schema.Unknown)],
+)
+type OpenResponsesProviderItem = Schema.Schema.Type<typeof ProviderItem>
 
 // `function_call_output.output` accepts either a plain string or an ordered
 // array of content items so tools can return images and files in addition to text.
@@ -91,29 +95,42 @@ const OpenResponsesFunctionCallOutput = Schema.Union([
 
 export const InputItem = Schema.Union([
   Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
-  Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
   Schema.Struct({
+    type: Schema.optionalKey(Schema.tag("message")),
+    id: Schema.optionalKey(Schema.String),
+    role: Schema.tag("user"),
+    content: Schema.Array(OpenResponsesInputContent),
+    status: Schema.optionalKey(Schema.Literals(["in_progress", "completed", "incomplete"])),
+  }),
+  Schema.Struct({
+    type: Schema.optionalKey(Schema.tag("message")),
+    id: Schema.optionalKey(Schema.String),
     role: Schema.tag("assistant"),
     content: Schema.Array(OpenResponsesOutputText),
+    status: Schema.optionalKey(Schema.Literals(["in_progress", "completed", "incomplete"])),
     phase: Schema.optionalKey(MessagePhase),
   }),
   OpenResponsesReasoningItem,
-  OpenResponsesItemReference,
   Schema.Struct({
     type: Schema.tag("function_call"),
+    id: Schema.optionalKey(Schema.String),
     call_id: Schema.String,
     name: Schema.String,
     arguments: Schema.String,
+    status: Schema.optionalKey(Schema.Literals(["in_progress", "completed", "incomplete"])),
   }),
   Schema.Struct({
     type: Schema.tag("function_call_output"),
+    id: Schema.optionalKey(Schema.String),
     call_id: Schema.String,
     output: OpenResponsesFunctionCallOutput,
+    status: Schema.optionalKey(Schema.Literals(["in_progress", "completed", "incomplete"])),
   }),
 ])
 type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
 type LoweredInputItem =
   | OpenResponsesInputItem
+  | OpenResponsesProviderItem
   | {
       readonly role: "assistant"
       readonly content: ReadonlyArray<{ readonly type: "output_text"; readonly text: string }>
@@ -128,7 +145,7 @@ type OpenResponsesReasoningInput = {
   summary: Array<{ type: "summary_text"; text: string }>
   encrypted_content?: string | null
 }
-type OpenResponsesReasoningReplay = Omit<OpenResponsesReasoningInput, "id">
+type OpenResponsesReasoningReplay = OpenResponsesReasoningInput
 
 export const Tool = Schema.Struct({
   type: Schema.tag("function"),
@@ -310,12 +327,27 @@ export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LL
     tool: (toolName) => ({ type: "function" as const, name: toolName }),
   })
 
-const lowerToolCall = (part: ToolCallPart): OpenResponsesInputItem => ({
-  type: "function_call",
-  call_id: part.id,
-  name: part.name,
-  arguments: ProviderShared.encodeJson(part.input),
-})
+const responseItemID = (prefix: string, id: string) => {
+  const value = id.replace(/^[^_]+_/, "").replace(/[^a-zA-Z0-9_-]/g, "_")
+  return `${prefix}_${value || "item"}`.slice(0, 64)
+}
+
+const responseItemMetadata = (part: { readonly providerMetadata?: ProviderMetadata }, providerMetadataKey: string) => {
+  const metadata = part.providerMetadata?.[providerMetadataKey]
+  return ProviderShared.isRecord(metadata) ? metadata : undefined
+}
+
+const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => {
+  const metadata = responseItemMetadata(part, providerMetadataKey)
+  return {
+    type: "function_call",
+    id: typeof metadata?.itemId === "string" ? metadata.itemId : responseItemID("fc", part.id),
+    call_id: part.id,
+    name: part.name,
+    arguments: ProviderShared.encodeJson(part.input),
+    status: "completed",
+  }
+}
 
 const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
   const metadata = part.providerMetadata?.[providerMetadataKey]
@@ -333,11 +365,22 @@ const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenR
   }
 }
 
-const hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) => {
-  const metadata = part.providerMetadata?.[providerMetadataKey]
-  return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
-    ? metadata.itemId
-    : undefined
+const hostedToolItem = (part: ToolResultPart, providerMetadataKey: string): OpenResponsesProviderItem | undefined => {
+  const metadata = responseItemMetadata(part, providerMetadataKey)
+  if (
+    ProviderShared.isRecord(metadata?.responseItem) &&
+    typeof metadata.responseItem.id === "string" &&
+    typeof metadata.responseItem.type === "string"
+  )
+    return { ...metadata.responseItem, type: metadata.responseItem.type, id: metadata.responseItem.id }
+  if (
+    part.result.type === "json" &&
+    ProviderShared.isRecord(part.result.value) &&
+    typeof part.result.value.id === "string" &&
+    typeof part.result.value.type === "string"
+  )
+    return { ...part.result.value, type: part.result.value.type, id: part.result.value.id }
+  return undefined
 }
 
 const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
@@ -400,14 +443,15 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
   const system: LoweredInputItem[] =
     request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
   const input: LoweredInputItem[] = [...system]
-  const store = OpenResponsesOptions.resolve(request).store
   const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
 
+  // `store` controls server persistence, not client-managed history. Replay the
+  // same stable item identities for stored and stateless requests.
   for (const message of request.messages) {
     if (message.role === "system") {
       const part = yield* ProviderShared.wrappedSystemUpdate(extension.name, message)
       const previous = input.at(-1)
-      if (previous && "role" in previous && previous.role === "user")
+      if (previous && "role" in previous && previous.role === "user" && Array.isArray(previous.content))
         input[input.length - 1] = {
           role: "user",
           content: [...previous.content, { type: "input_text", text: part.text }],
@@ -427,8 +471,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
     if (message.role === "assistant") {
       const content: TextPart[] = []
       const reasoningItems: Record<string, OpenResponsesReasoningReplay> = {}
-      const reasoningReferences = new Set<string>()
-      const hostedToolReferences = new Set<string>()
+      const hostedToolItems = new Set<string>()
       const flushText = () => {
         if (content.length === 0) return
         const groups = content.reduce<Array<{ phase: MessagePhase | null | undefined; parts: TextPart[] }>>(
@@ -443,11 +486,26 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
           [],
         )
         input.push(
-          ...groups.map((group) => ({
-            role: "assistant" as const,
-            content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
-            ...(group.phase === undefined ? {} : { phase: group.phase }),
-          })),
+          ...groups.map((group, index) => {
+            const first = group.parts[0]
+            const metadata = first ? responseItemMetadata(first, providerMetadataKey) : undefined
+            const id =
+              typeof metadata?.itemId === "string"
+                ? metadata.itemId
+                : message.id === undefined
+                  ? undefined
+                  : index === 0
+                    ? message.id
+                    : responseItemID("msg", `${message.id}_${index}`)
+            return {
+              type: "message" as const,
+              ...(id === undefined ? {} : { id }),
+              role: "assistant" as const,
+              content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
+              status: "completed" as const,
+              ...(group.phase === undefined ? {} : { phase: group.phase }),
+            }
+          }),
         )
         content.splice(0, content.length)
       }
@@ -460,11 +518,6 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
           flushText()
           const reasoning = lowerReasoning(part, providerMetadataKey)
           if (!reasoning) continue
-          if (store !== false) {
-            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)
@@ -472,11 +525,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
               existing.encrypted_content = reasoning.encrypted_content
             continue
           }
-          const replay = {
-            type: reasoning.type,
-            summary: reasoning.summary,
-            encrypted_content: reasoning.encrypted_content,
-          }
+          const replay = { ...reasoning }
           reasoningItems[reasoning.id] = replay
           input.push(replay)
           continue
@@ -484,22 +533,21 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
         if (part.type === "tool-call") {
           flushText()
           if (part.providerExecuted === true) continue
-          input.push(lowerToolCall(part))
+          input.push(lowerToolCall(part, providerMetadataKey))
           continue
         }
         if (part.type === "tool-result" && part.providerExecuted === true) {
           flushText()
-          const itemID = hostedToolItemID(part, providerMetadataKey)
-          if (store !== false && itemID && !hostedToolReferences.has(itemID))
-            input.push({ type: "item_reference", id: itemID })
-          if (store === false && part.result.type === "content") {
+          const item = hostedToolItem(part, providerMetadataKey)
+          if (item && !hostedToolItems.has(item.id)) input.push(item)
+          if (!item && part.result.type === "content") {
             const content: ReadonlyArray<Content> = part.result.value
             input.push({
               role: "user",
               content: yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension)),
             })
           }
-          if (itemID) hostedToolReferences.add(itemID)
+          if (item) hostedToolItems.add(item.id)
           continue
         }
         return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
@@ -518,20 +566,15 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
         return yield* ProviderShared.unsupportedContent(extension.name, "tool", ["tool-result"])
       input.push({
         type: "function_call_output",
+        id: responseItemID("fco", part.id),
         call_id: part.id,
         output: yield* lowerToolResultOutput(part, request, extension),
+        status: "completed",
       })
     }
   }
 
-  // With store:false, Responses APIs only accept 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
+  return input
 })
 
 const lowerOptions = (request: LLMRequest) => {
@@ -641,7 +684,7 @@ const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepRe
   if (!event.delta) return [state, NO_EVENTS]
   const events: LLMEvent[] = []
   const phase = state.messagePhases[id]
-  const metadata = phase === undefined ? undefined : providerMetadata(state, { phase })
+  const metadata = providerMetadata(state, { itemId: id, ...(phase === undefined ? {} : { phase }) })
   const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata)
   return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) }, events]
 }
@@ -652,7 +695,13 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
     return onOutputTextDelta(state, { ...event, delta: event.text }, id)
   }
   const events: LLMEvent[] = []
-  return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events]
+  return [
+    {
+      ...state,
+      lifecycle: Lifecycle.textEnd(state.lifecycle, events, id, providerMetadata(state, { itemId: id })),
+    },
+    events,
+  ]
 }
 
 export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
@@ -870,7 +919,10 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
           state.lifecycle,
           events,
           item.id,
-          phase === undefined ? undefined : providerMetadata(state, { phase }),
+          providerMetadata(state, {
+            itemId: item.id,
+            ...(phase === undefined ? {} : { phase }),
+          }),
         ),
         messageItems,
         messagePhases,

+ 1 - 0
packages/ai/src/protocols/openai-compatible-responses.ts

@@ -17,6 +17,7 @@ export const route = Route.make({
   protocol: OpenResponses.protocol,
   endpoint: Endpoint.path(OpenResponses.PATH),
   transport: OpenResponses.httpTransport,
+  defaults: { providerOptions: { openresponses: { store: false } } },
 })
 
 export * as OpenAICompatibleResponses from "./openai-compatible-responses"

+ 8 - 3
packages/ai/src/protocols/openai-responses.ts

@@ -37,10 +37,14 @@ const OpenAIResponsesToolChoice = Schema.Union([
 
 const OpenAIResponsesInputItem = Schema.Union([
   Schema.Struct({
+    type: Schema.optionalKey(Schema.tag("message")),
+    id: Schema.optionalKey(Schema.String),
     role: Schema.tag("assistant"),
     content: Schema.Array(Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String })),
+    status: Schema.optionalKey(Schema.Literals(["in_progress", "completed", "incomplete"])),
     phase: Schema.optionalKey(Schema.NullOr(OpenResponses.MessagePhase)),
   }),
+  OpenResponses.ProviderItem,
   OpenResponses.InputItem,
 ])
 
@@ -195,7 +199,8 @@ const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function*
   item: HostedToolItem,
 ) {
   const tool = HOSTED_TOOLS[item.type]
-  const providerMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
+  const callMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
+  const resultMetadata = OpenResponses.providerMetadata(state, { itemId: item.id, responseItem: item })
   const events: LLMEvent[] = []
   const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
   events.push(
@@ -204,14 +209,14 @@ const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function*
       name: tool.name,
       input: tool.input(item),
       providerExecuted: true,
-      providerMetadata,
+      providerMetadata: callMetadata,
     }),
     LLMEvent.toolResult({
       id: item.id,
       name: tool.name,
       result: yield* hostedToolResult(item),
       providerExecuted: true,
-      providerMetadata,
+      providerMetadata: resultMetadata,
     }),
   )
   return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult

+ 12 - 12
packages/ai/src/protocols/utils/lifecycle.ts

@@ -2,11 +2,11 @@ import { LLMEvent, type FinishReasonDetails, type ProviderMetadata, type Usage }
 
 export interface State {
   readonly stepStarted: boolean
-  readonly text: ReadonlySet<string>
-  readonly reasoning: ReadonlySet<string>
+  readonly text: ReadonlyMap<string, ProviderMetadata | undefined>
+  readonly reasoning: ReadonlyMap<string, ProviderMetadata | undefined>
 }
 
-export const initial = (): State => ({ stepStarted: false, text: new Set(), reasoning: new Set() })
+export const initial = (): State => ({ stepStarted: false, text: new Map(), reasoning: new Map() })
 
 export const stepStart = (state: State, events: LLMEvent[]): State => {
   if (state.stepStarted) return state
@@ -18,7 +18,7 @@ export const textStart = (state: State, events: LLMEvent[], id: string, provider
   if (state.text.has(id)) return state
   const stepped = stepStart(state, events)
   events.push(LLMEvent.textStart({ id, providerMetadata }))
-  return { ...stepped, text: new Set([...stepped.text, id]) }
+  return { ...stepped, text: new Map([...stepped.text, [id, providerMetadata]]) }
 }
 
 export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
@@ -36,7 +36,7 @@ export const reasoningStart = (
   if (state.reasoning.has(id)) return state
   const stepped = stepStart(state, events)
   events.push(LLMEvent.reasoningStart({ id, providerMetadata }))
-  return { ...stepped, reasoning: new Set([...stepped.reasoning, id]) }
+  return { ...stepped, reasoning: new Map([...stepped.reasoning, [id, providerMetadata]]) }
 }
 
 export const reasoningDelta = (
@@ -59,8 +59,8 @@ export const reasoningEnd = (
 ): State => {
   if (!state.reasoning.has(id)) return state
   const stepped = stepStart(state, events)
-  events.push(LLMEvent.reasoningEnd({ id, providerMetadata }))
-  const reasoning = new Set(stepped.reasoning)
+  events.push(LLMEvent.reasoningEnd({ id, providerMetadata: providerMetadata ?? stepped.reasoning.get(id) }))
+  const reasoning = new Map(stepped.reasoning)
   reasoning.delete(id)
   return { ...stepped, reasoning }
 }
@@ -68,16 +68,16 @@ export const reasoningEnd = (
 export const textEnd = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
   if (!state.text.has(id)) return state
   const stepped = stepStart(state, events)
-  events.push(LLMEvent.textEnd({ id, providerMetadata }))
-  const text = new Set(stepped.text)
+  events.push(LLMEvent.textEnd({ id, providerMetadata: providerMetadata ?? stepped.text.get(id) }))
+  const text = new Map(stepped.text)
   text.delete(id)
   return { ...stepped, text }
 }
 
 const closeOpenBlocks = (state: State, events: LLMEvent[]): State => {
-  for (const id of state.reasoning) events.push(LLMEvent.reasoningEnd({ id }))
-  for (const id of state.text) events.push(LLMEvent.textEnd({ id }))
-  return { ...state, text: new Set(), reasoning: new Set() }
+  for (const [id, providerMetadata] of state.reasoning) events.push(LLMEvent.reasoningEnd({ id, providerMetadata }))
+  for (const [id, providerMetadata] of state.text) events.push(LLMEvent.textEnd({ id, providerMetadata }))
+  return { ...state, text: new Map(), reasoning: new Map() }
 }
 
 export const finish = (

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
packages/ai/test/fixtures/recordings/openai-responses-images/generates-and-edits-an-image-with-the-hosted-tool.json


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
packages/ai/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json


+ 1 - 1
packages/ai/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json

@@ -44,7 +44,7 @@
         "headers": {
           "content-type": "application/json"
         },
-        "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEjXoGMCw3WDXpoD9151PEr2Lt8raW7KBKefQhZJGWx5f8jy152bApO6oE-Mr1BhUtfZNq3OPBVfSL4ioQ9bHREfujIBXgk9LUDBAz2Sle7KjOr9HaUV16A4HBiaFIRFjsHPS9G8yEySp1m6F1CD_WR6apyUGgugRh_y39EcOJmxPOzmiac5DVM6fraA1VpcGbqrZ1x2ANHFDOfnYTycPtPNTgzE7LjkYjDDWbT03uN1YxfP4pqjDVRzY14pA8bSZ8ys-pDv5kUFCAsw-OlU4jYKUXp-M8_6KTaRQP71LPwppt__zG_NJPfy-qUil4pOU8_NoxtxerHgLLXbfExZdzfpoGinoEjn7nj7BJDEtl-LNeNEb5c-1ZymNfVMp-Cs3fLEPkAV8rtHFtZ0MhE_07GKbGo7hTrOmkM4DydxmHsdWGNbXAG35cprslEA5P7p3GHFKnRs5hGs2eq-XcZ3yki64ZBOU_Tv6UR7nUH09gF1rdrJo3dpre6M00COwwdZ02zUP5KxCuI8FKu2jsZu9zgMVXDALsdtM5orTCVLXsn4rddWd111zE-vMjNmMMmktW2cHMjH7j1ooA-9P083koNVYiLi4UhMA64gTqgyl8MxkZekl7eFSMa7qk295NaHOKtFxzYYcZ9jdioCwSPSZ0ZZWLoNgrK7SWfRh0uaTHNcMZ3wq8ae6CguktIeVTCPTQAqJLQqd7AU0oOCKCJ7BWnC-L8UC6m7Pm9ZS958uUVeWBhgKHzMAGq9UeQB7IEeAcbMn3EDgOSfd8qCb8iwU9iG9dcu9axQwWU7pd7kd-T-He61W7z5wWgpx1KehWCxrN6kuKSo6p-uUfwVnJukreOn8BJNAzADQgz68bhmN9VGih7YcKVnLgwDwKditrjSd6-tfE0Baarj3jWENvT6ohY17R9FDrKS-2v8IIX6tGjoKJw8SRhaWLNv4vWlmxRgR0gdac3qumd0GKqsWSveNz01naA==\"},{\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}"
+        "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEjXoGMCw3WDXpoD9151PEr2Lt8raW7KBKefQhZJGWx5f8jy152bApO6oE-Mr1BhUtfZNq3OPBVfSL4ioQ9bHREfujIBXgk9LUDBAz2Sle7KjOr9HaUV16A4HBiaFIRFjsHPS9G8yEySp1m6F1CD_WR6apyUGgugRh_y39EcOJmxPOzmiac5DVM6fraA1VpcGbqrZ1x2ANHFDOfnYTycPtPNTgzE7LjkYjDDWbT03uN1YxfP4pqjDVRzY14pA8bSZ8ys-pDv5kUFCAsw-OlU4jYKUXp-M8_6KTaRQP71LPwppt__zG_NJPfy-qUil4pOU8_NoxtxerHgLLXbfExZdzfpoGinoEjn7nj7BJDEtl-LNeNEb5c-1ZymNfVMp-Cs3fLEPkAV8rtHFtZ0MhE_07GKbGo7hTrOmkM4DydxmHsdWGNbXAG35cprslEA5P7p3GHFKnRs5hGs2eq-XcZ3yki64ZBOU_Tv6UR7nUH09gF1rdrJo3dpre6M00COwwdZ02zUP5KxCuI8FKu2jsZu9zgMVXDALsdtM5orTCVLXsn4rddWd111zE-vMjNmMMmktW2cHMjH7j1ooA-9P083koNVYiLi4UhMA64gTqgyl8MxkZekl7eFSMa7qk295NaHOKtFxzYYcZ9jdioCwSPSZ0ZZWLoNgrK7SWfRh0uaTHNcMZ3wq8ae6CguktIeVTCPTQAqJLQqd7AU0oOCKCJ7BWnC-L8UC6m7Pm9ZS958uUVeWBhgKHzMAGq9UeQB7IEeAcbMn3EDgOSfd8qCb8iwU9iG9dcu9axQwWU7pd7kd-T-He61W7z5wWgpx1KehWCxrN6kuKSo6p-uUfwVnJukreOn8BJNAzADQgz68bhmN9VGih7YcKVnLgwDwKditrjSd6-tfE0Baarj3jWENvT6ohY17R9FDrKS-2v8IIX6tGjoKJw8SRhaWLNv4vWlmxRgR0gdac3qumd0GKqsWSveNz01naA==\",\"id\":\"rs_0a0794dab3b8ec7d016a1235e7ce3881958a5eca32a36a14c5\"},{\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\"}],\"type\":\"message\",\"status\":\"completed\",\"id\":\"msg_0a0794dab3b8ec7d016a1235e8d64c81959a41f8db3ea7b66c\",\"phase\":\"final_answer\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}"
       },
       "response": {
         "status": 200,

+ 1 - 1
packages/ai/test/fixtures/recordings/pdf/openai-tool-result.json

@@ -21,7 +21,7 @@
         "headers": {
           "content-type": "application/json"
         },
-        "body": "{\"model\":\"gpt-4o-mini\",\"input\":[{\"role\":\"system\",\"content\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_pdf_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"PDF read successfully\"},{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}"
+        "body": "{\"model\":\"gpt-4o-mini\",\"input\":[{\"role\":\"system\",\"content\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"arguments\":\"{}\",\"id\":\"fc_pdf_1\",\"status\":\"completed\"},{\"type\":\"function_call_output\",\"call_id\":\"call_pdf_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"PDF read successfully\"},{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}],\"id\":\"fco_pdf_1\",\"status\":\"completed\"}],\"tools\":[{\"type\":\"function\",\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}"
       },
       "response": {
         "status": 200,

+ 1 - 1
packages/ai/test/fixtures/recordings/pdf/xai-tool-result.json

@@ -21,7 +21,7 @@
         "headers": {
           "content-type": "application/json"
         },
-        "body": "{\"model\":\"grok-4.5\",\"input\":[{\"role\":\"system\",\"content\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_pdf_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"PDF read successfully\"},{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\",\"mime_type\":\"application/pdf\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}"
+        "body": "{\"model\":\"grok-4.5\",\"input\":[{\"role\":\"system\",\"content\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"arguments\":\"{}\",\"id\":\"fc_pdf_1\",\"status\":\"completed\"},{\"type\":\"function_call_output\",\"call_id\":\"call_pdf_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"PDF read successfully\"},{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\",\"mime_type\":\"application/pdf\"}],\"id\":\"fco_pdf_1\",\"status\":\"completed\"}],\"tools\":[{\"type\":\"function\",\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}"
       },
       "response": {
         "status": 200,

+ 40 - 0
packages/ai/test/provider/openai-compatible-responses.test.ts

@@ -51,7 +51,13 @@ describe("Open Responses-compatible route", () => {
           { role: "system", content: "You are concise." },
           { role: "user", content: [{ type: "input_text", text: "Say hello." }] },
         ],
+        store: false,
         stream: true,
+        max_output_tokens: undefined,
+        temperature: undefined,
+        tool_choice: undefined,
+        tools: undefined,
+        top_p: undefined,
       })
     }),
   )
@@ -112,6 +118,40 @@ describe("Open Responses-compatible route", () => {
     }),
   )
 
+  it.effect("keeps response item replay independent of store", () =>
+    Effect.gen(function* () {
+      const model = configure({
+        apiKey: "test-key",
+        baseURL: "https://responses.example.test/v1",
+      }).model("example-model")
+      const messages = [
+        Message.assistant({
+          type: "reasoning",
+          text: "Checked the previous diff.",
+          providerMetadata: {
+            openresponses: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
+          },
+        }),
+      ]
+      const stored = yield* compileRequest(
+        LLM.request({ model, messages, providerOptions: { openresponses: { store: true } } }),
+      )
+      const stateless = yield* compileRequest(
+        LLM.request({ model, messages, providerOptions: { openresponses: { store: false } } }),
+      )
+
+      expect(stored.body.input).toEqual(stateless.body.input)
+      expect(stored.body.input).toEqual([
+        {
+          type: "reasoning",
+          id: "rs_1",
+          summary: [{ type: "summary_text", text: "Checked the previous diff." }],
+          encrypted_content: "encrypted-state",
+        },
+      ])
+    }),
+  )
+
   it.effect("does not interpret OpenAI hosted-tool items", () =>
     Effect.gen(function* () {
       const model = configure({

+ 140 - 25
packages/ai/test/provider/openai-responses.test.ts

@@ -211,7 +211,12 @@ describe("OpenAI Responses route", () => {
             { type: "input_text", text: "<system-update>\nTreat &lt;/system-update&gt; literally.\n</system-update>" },
           ],
         },
-        { role: "assistant", content: [{ type: "output_text", text: "After." }] },
+        {
+          type: "message",
+          role: "assistant",
+          content: [{ type: "output_text", text: "After." }],
+          status: "completed",
+        },
       ])
     }),
   )
@@ -329,7 +334,7 @@ describe("OpenAI Responses route", () => {
       yield* LLMClient.generate(
         LLMRequest.update(request, {
           model: Azure.configure({
-            baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
+            resourceName: "opencode-test",
             apiKey: "azure-key",
             headers: { authorization: "Bearer stale" },
           }).responses("gpt-4.1-mini"),
@@ -414,8 +419,21 @@ describe("OpenAI Responses route", () => {
         model: "gpt-4.1-mini",
         input: [
           { role: "user", content: [{ type: "input_text", text: "What is the weather?" }] },
-          { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
-          { type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' },
+          {
+            type: "function_call",
+            id: "fc_1",
+            call_id: "call_1",
+            name: "lookup",
+            arguments: '{"query":"weather"}',
+            status: "completed",
+          },
+          {
+            type: "function_call_output",
+            id: "fco_1",
+            call_id: "call_1",
+            output: '{"forecast":"sunny"}',
+            status: "completed",
+          },
         ],
         store: false,
         stream: true,
@@ -864,10 +882,10 @@ describe("OpenAI Responses route", () => {
       expect(response.text).toBe("Hello!")
       expect(response.events).toEqual([
         { type: "step-start", index: 0 },
-        { type: "text-start", id: "msg_1" },
+        { type: "text-start", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
         { type: "text-delta", id: "msg_1", text: "Hello" },
         { type: "text-delta", id: "msg_1", text: "!" },
-        { type: "text-end", id: "msg_1" },
+        { type: "text-end", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
         {
           type: "step-finish",
           index: 0,
@@ -923,35 +941,44 @@ describe("OpenAI Responses route", () => {
         {
           type: "text",
           text: "Checking.",
-          providerMetadata: { openai: { phase: "commentary" } },
+          providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
         },
         {
           type: "text",
           text: "Finished.",
-          providerMetadata: { openai: { phase: "final_answer" } },
+          providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } },
         },
         {
           type: "text",
           text: "Unclassified.",
-          providerMetadata: { openai: { phase: null } },
+          providerMetadata: { openai: { itemId: "msg_null", phase: null } },
         },
       ])
 
       const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
       expect(prepared.body.input).toEqual([
         {
+          type: "message",
+          id: "msg_commentary",
           role: "assistant",
           content: [{ type: "output_text", text: "Checking." }],
+          status: "completed",
           phase: "commentary",
         },
         {
+          type: "message",
+          id: "msg_final",
           role: "assistant",
           content: [{ type: "output_text", text: "Finished." }],
+          status: "completed",
           phase: "final_answer",
         },
         {
+          type: "message",
+          id: "msg_null",
           role: "assistant",
           content: [{ type: "output_text", text: "Unclassified." }],
+          status: "completed",
           phase: null,
         },
       ])
@@ -1043,12 +1070,12 @@ describe("OpenAI Responses route", () => {
       )
 
       expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([
-        { type: "text-start", id: "msg_1" },
+        { type: "text-start", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
         { type: "text-delta", id: "msg_1", text: "First" },
-        { type: "text-end", id: "msg_1" },
-        { type: "text-start", id: "msg_2" },
+        { type: "text-end", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
+        { type: "text-start", id: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
         { type: "text-delta", id: "msg_2", text: "Second" },
-        { type: "text-end", id: "msg_2" },
+        { type: "text-end", id: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
       ])
     }),
   )
@@ -1070,7 +1097,7 @@ describe("OpenAI Responses route", () => {
         { type: "step-start", index: 0 },
         { type: "reasoning-start", id: "rs_1" },
         { type: "reasoning-delta", id: "rs_1", text: "thinking" },
-        { type: "text-start", id: "msg_1" },
+        { type: "text-start", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
         { type: "text-delta", id: "msg_1", text: "Hello" },
         { type: "reasoning-end", id: "rs_1" },
         { type: "text-end", id: "msg_1" },
@@ -1080,7 +1107,7 @@ describe("OpenAI Responses route", () => {
       expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
       expect(response.message.content).toEqual([
         { type: "reasoning", text: "thinking" },
-        { type: "text", text: "Hello" },
+        { type: "text", text: "Hello", providerMetadata: { openai: { itemId: "msg_1" } } },
       ])
     }),
   )
@@ -1250,7 +1277,7 @@ describe("OpenAI Responses route", () => {
                   { role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
                 ],
               })
-              expect(body.input[1]).not.toHaveProperty("id")
+              expect(body.input[1]).toHaveProperty("id", "rs_1")
               return input.respond(
                 sseEvents(
                   { type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." },
@@ -1267,6 +1294,67 @@ describe("OpenAI Responses route", () => {
     }),
   )
 
+  it.effect("keeps OpenAI and Azure response item replay independent of store", () =>
+    Effect.gen(function* () {
+      const models = [
+        model,
+        Azure.configure({ resourceName: "opencode-test", apiKey: "azure-key" }).responses("gpt-4.1-mini"),
+      ]
+      const messages = [
+        Message.assistant([
+          {
+            type: "reasoning" as const,
+            text: "Checked the previous diff.",
+            providerMetadata: {
+              openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
+            },
+          },
+          ToolCallPart.make({
+            id: "call_1",
+            name: "lookup",
+            input: { query: "weather" },
+            providerMetadata: { openai: { itemId: "fc_1" } },
+          }),
+        ]),
+        Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }),
+      ]
+
+      for (const current of models) {
+        const stored = yield* compileRequest(
+          LLM.request({ model: current, messages, providerOptions: { openai: { store: true } } }),
+        )
+        const stateless = yield* compileRequest(
+          LLM.request({ model: current, messages, providerOptions: { openai: { store: false } } }),
+        )
+
+        expect(stored.body.input).toEqual(stateless.body.input)
+        expect(stored.body.input).toEqual([
+          {
+            type: "reasoning",
+            id: "rs_1",
+            summary: [{ type: "summary_text", text: "Checked the previous diff." }],
+            encrypted_content: "encrypted-state",
+          },
+          {
+            type: "function_call",
+            id: "fc_1",
+            call_id: "call_1",
+            name: "lookup",
+            arguments: '{"query":"weather"}',
+            status: "completed",
+          },
+          {
+            type: "function_call_output",
+            id: "fco_1",
+            call_id: "call_1",
+            output: '{"forecast":"sunny"}',
+            status: "completed",
+          },
+        ])
+      }
+    }),
+  )
+
   it.effect("preserves assistant content order around reasoning items", () =>
     Effect.gen(function* () {
       const prepared = yield* compileRequest(
@@ -1294,18 +1382,29 @@ describe("OpenAI Responses route", () => {
       )
 
       expect(prepared.body.input).toEqual([
-        { role: "assistant", content: [{ type: "output_text", text: "Before." }] },
+        {
+          type: "message",
+          role: "assistant",
+          content: [{ type: "output_text", text: "Before." }],
+          status: "completed",
+        },
         {
           type: "reasoning",
+          id: "rs_1",
           encrypted_content: "encrypted-state",
           summary: [{ type: "summary_text", text: "Checked order." }],
         },
-        { role: "assistant", content: [{ type: "output_text", text: "After." }] },
+        {
+          type: "message",
+          role: "assistant",
+          content: [{ type: "output_text", text: "After." }],
+          status: "completed",
+        },
       ])
     }),
   )
 
-  it.effect("references stored reasoning items by id", () =>
+  it.effect("replays stored reasoning items with their id", () =>
     Effect.gen(function* () {
       const prepared = yield* compileRequest(
         LLM.request({
@@ -1323,11 +1422,18 @@ describe("OpenAI Responses route", () => {
         }),
       )
 
-      expect(prepared.body.input).toEqual([{ type: "item_reference", id: "rs_1" }])
+      expect(prepared.body.input).toEqual([
+        {
+          type: "reasoning",
+          id: "rs_1",
+          summary: [{ type: "summary_text", text: "Checked the previous diff." }],
+          encrypted_content: undefined,
+        },
+      ])
     }),
   )
 
-  it.effect("references stored provider-executed hosted tool results by id", () =>
+  it.effect("replays stored provider-executed hosted tool results", () =>
     Effect.gen(function* () {
       const prepared = yield* compileRequest(
         LLM.request({
@@ -1357,7 +1463,7 @@ describe("OpenAI Responses route", () => {
       )
 
       expect(prepared.body.input).toEqual([
-        { type: "item_reference", id: "ws_1" },
+        { type: "web_search_call", id: "ws_1", status: "completed" },
         { role: "user", content: [{ type: "input_text", text: "Continue." }] },
       ])
     }),
@@ -1432,6 +1538,7 @@ describe("OpenAI Responses route", () => {
       expect(prepared.body.input).toEqual([
         {
           type: "reasoning",
+          id: "rs_1",
           encrypted_content: "encrypted-state",
           summary: [
             { type: "summary_text", text: "First" },
@@ -1442,7 +1549,7 @@ describe("OpenAI Responses route", () => {
     }),
   )
 
-  it.effect("skips non-persisted reasoning ids without encrypted state", () =>
+  it.effect("replays reasoning ids without encrypted state", () =>
     Effect.gen(function* () {
       const prepared = yield* compileRequest(
         LLM.request({
@@ -1472,6 +1579,12 @@ describe("OpenAI Responses route", () => {
       expect(prepared.body).toMatchObject({
         input: [
           { role: "user", content: [{ type: "input_text", text: "What changed?" }] },
+          {
+            type: "reasoning",
+            id: "rs_1",
+            summary: [{ type: "summary_text", text: "Checked the previous diff." }],
+            encrypted_content: null,
+          },
           { role: "assistant", content: [{ type: "output_text", text: "The parser changed." }] },
           { role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
         ],
@@ -1663,7 +1776,8 @@ describe("OpenAI Responses route", () => {
           name: "web_search",
           result: { type: "json", value: item },
           providerExecuted: true,
-          providerMetadata: { openai: { itemId: "ws_1" } },
+          output: undefined,
+          providerMetadata: { openai: { itemId: "ws_1", responseItem: item } },
         },
       ])
     }),
@@ -1754,7 +1868,8 @@ describe("OpenAI Responses route", () => {
         name: "code_interpreter",
         result: { type: "json", value: item },
         providerExecuted: true,
-        providerMetadata: { openai: { itemId: "ci_1" } },
+        output: undefined,
+        providerMetadata: { openai: { itemId: "ci_1", responseItem: item } },
       })
     }),
   )

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

@@ -261,7 +261,7 @@ const assistantMessageFromResponse = (response: LLMResponse, step: AssistantStep
     content.push({ type: "reasoning", text: response.reasoning, providerMetadata: reasoning.providerMetadata })
   }
 
-  if (response.text.length > 0) content.push({ type: "text", text: response.text })
+  content.push(...response.message.content.filter((part) => part.type === "text"))
   content.push(...response.toolCalls)
   return Message.assistant(content)
 }

Неке датотеке нису приказане због велике количине промена