Răsfoiți Sursa

fix(ai): preserve responses item ids

Aiden Cline 1 săptămână în urmă
părinte
comite
a1cbcc8641

+ 32 - 1
packages/ai/src/llm.ts

@@ -9,6 +9,7 @@ import {
   LLMRequest,
   LLMResponse,
   Message,
+  ResponseItemID,
   LanguageModel,
   SystemPart,
   ToolChoice,
@@ -38,6 +39,17 @@ export const generate = LLMClient.generate
 
 export const stream = LLMClient.stream
 
+const providerItemID = (part: ContentPart, providerMetadataKey: string) => {
+  if (part.type === "media" || part.itemId !== undefined) return part.type === "media" ? undefined : part.itemId
+  const metadata = part.providerMetadata?.[providerMetadataKey]
+  return typeof metadata === "object" &&
+    metadata !== null &&
+    !Array.isArray(metadata) &&
+    typeof metadata.itemId === "string"
+    ? metadata.itemId
+    : undefined
+}
+
 export const request = <const SelectedLanguageModel extends LanguageModel>(
   input: RequestInput<SelectedLanguageModel>,
 ) => {
@@ -52,10 +64,29 @@ export const request = <const SelectedLanguageModel extends LanguageModel>(
     http: requestHttp,
     ...rest
   } = input
+  const normalized = [...(messages?.map(Message.make) ?? []), ...(prompt === undefined ? [] : [Message.user(prompt)])]
+  const providerMetadataKey = input.model.route.providerMetadataKey ?? input.model.provider
   return new LLMRequest({
     ...rest,
     system: SystemPart.content(requestSystem),
-    messages: [...(messages?.map(Message.make) ?? []), ...(prompt === undefined ? [] : [Message.user(prompt)])],
+    messages: normalized.map((message) => {
+      const id = message.id ?? ResponseItemID.create("msg")
+      return new Message({
+        id,
+        role: message.role,
+        content: message.content.map((part, index) => {
+          if (part.type === "media" || message.role === "user" || message.role === "system") return part
+          const itemId = providerItemID(part, providerMetadataKey)
+          if (itemId !== undefined) return { ...part, itemId }
+          if (part.type === "tool-call") return { ...part, itemId: `fc_${id}_${index}` }
+          if (part.type === "tool-result") return { ...part, itemId: `fco_${id}_${index}` }
+          if (part.type === "reasoning") return { ...part, itemId: `rs_${id}_${index}` }
+          return { ...part, itemId: `msg_${id}_${index}` }
+        }),
+        metadata: message.metadata,
+        native: message.native,
+      })
+    }),
     tools: tools?.map(ToolDefinition.make) ?? [],
     toolChoice: requestToolChoice ? ToolChoice.make(requestToolChoice) : undefined,
     generation: requestGeneration === undefined ? undefined : GenerationOptions.make(requestGeneration),

+ 135 - 53
packages/ai/src/protocols/open-responses.ts

@@ -11,6 +11,7 @@ import {
   type LLMRequest,
   type MediaPart,
   type ProviderMetadata,
+  ResponseItemID,
   type ReasoningPart,
   type TextPart,
   type ToolCallPart,
@@ -90,10 +91,15 @@ 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({ role: Schema.tag("system"), id: Schema.optionalKey(Schema.String), content: Schema.String }),
+  Schema.Struct({
+    role: Schema.tag("user"),
+    id: Schema.optionalKey(Schema.String),
+    content: Schema.Array(OpenResponsesInputContent),
+  }),
   Schema.Struct({
     role: Schema.tag("assistant"),
+    id: Schema.optionalKey(Schema.String),
     content: Schema.Array(OpenResponsesOutputText),
     phase: Schema.optionalKey(MessagePhase),
   }),
@@ -101,19 +107,23 @@ export const InputItem = Schema.Union([
   OpenResponsesItemReference,
   Schema.Struct({
     type: Schema.tag("function_call"),
+    id: Schema.optionalKey(Schema.String),
     call_id: Schema.String,
     name: Schema.String,
     arguments: Schema.String,
   }),
   Schema.Struct({
     type: Schema.tag("function_call_output"),
+    id: Schema.optionalKey(Schema.String),
     call_id: Schema.String,
     output: OpenResponsesFunctionCallOutput,
   }),
 ])
 type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
+type ProviderInputItem = Readonly<Record<string, unknown>> & { readonly type: string; readonly id?: string }
 type LoweredInputItem =
   | OpenResponsesInputItem
+  | ProviderInputItem
   | {
       readonly role: "assistant"
       readonly content: ReadonlyArray<{ readonly type: "output_text"; readonly text: string }>
@@ -128,7 +138,7 @@ type OpenResponsesReasoningInput = {
   summary: Array<{ type: "summary_text"; text: string }>
   encrypted_content?: string | null
 }
-type OpenResponsesReasoningReplay = Omit<OpenResponsesReasoningInput, "id">
+type OpenResponsesReasoningReplay = Omit<OpenResponsesReasoningInput, "id"> & { id?: string }
 
 export const Tool = Schema.Struct({
   type: Schema.tag("function"),
@@ -254,6 +264,11 @@ export interface Extension {
     readonly request: LLMRequest
   }) => MediaInput | undefined
   readonly messagePhase?: (value: unknown) => MessagePhase | null | undefined
+  readonly lowerProviderItem?: (
+    part: ToolResultPart,
+    providerMetadataKey: string,
+    store: boolean | undefined,
+  ) => ProviderInputItem | undefined
 }
 
 const BASE: Extension = { id: ADAPTER, name: NAME }
@@ -310,8 +325,24 @@ export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LL
     tool: (toolName) => ({ type: "function" as const, name: toolName }),
   })
 
-const lowerToolCall = (part: ToolCallPart): OpenResponsesInputItem => ({
+const metadataItemID = (
+  part: { readonly itemId?: string; readonly providerMetadata?: ProviderMetadata },
+  providerMetadataKey: string,
+) => {
+  if (part.itemId) return part.itemId
+  const metadata = part.providerMetadata?.[providerMetadataKey]
+  return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
+    ? metadata.itemId
+    : undefined
+}
+
+const outboundItemID = (id: string | undefined) => (id !== undefined && ResponseItemID.isPrefixed(id) ? id : undefined)
+
+const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => ({
   type: "function_call",
+  ...(outboundItemID(metadataItemID(part, providerMetadataKey)) === undefined
+    ? {}
+    : { id: outboundItemID(metadataItemID(part, providerMetadataKey)) }),
   call_id: part.id,
   name: part.name,
   arguments: ProviderShared.encodeJson(part.input),
@@ -319,26 +350,23 @@ const lowerToolCall = (part: ToolCallPart): OpenResponsesInputItem => ({
 
 const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
   const metadata = part.providerMetadata?.[providerMetadataKey]
-  if (!ProviderShared.isRecord(metadata) || typeof metadata.itemId !== "string" || metadata.itemId.length === 0)
-    return undefined
+  const itemId = metadataItemID(part, providerMetadataKey)
+  if (!itemId) return undefined
   const encryptedContent =
-    typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
+    ProviderShared.isRecord(metadata) &&
+    (typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null)
       ? metadata.reasoningEncryptedContent
       : undefined
   return {
     type: "reasoning",
-    id: metadata.itemId,
+    id: itemId,
     summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
     encrypted_content: encryptedContent,
   }
 }
 
-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 hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) =>
+  metadataItemID(part, providerMetadataKey)
 
 const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
   part: MediaPart,
@@ -397,28 +425,45 @@ const lowerToolResultOutput = Effect.fn("OpenResponses.lowerToolResultOutput")(f
 })
 
 const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
+  const options = OpenResponsesOptions.resolve(request)
+  const systemItemID = request.id ?? options.promptCacheKey
   const system: LoweredInputItem[] =
-    request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
+    request.system.length === 0
+      ? []
+      : [
+          {
+            role: "system",
+            ...(systemItemID === undefined ? {} : { id: `msg_${systemItemID}_system` }),
+            content: ProviderShared.joinText(request.system),
+          },
+        ]
   const input: LoweredInputItem[] = [...system]
-  const store = OpenResponsesOptions.resolve(request).store
+  const store = options.store
   const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
 
   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",
+          id: previous.id,
           content: [...previous.content, { type: "input_text", text: part.text }],
         }
-      else input.push({ role: "user", content: [{ type: "input_text", text: part.text }] })
+      else
+        input.push({
+          role: "user",
+          id: outboundItemID(message.id),
+          content: [{ type: "input_text", text: part.text }],
+        })
       continue
     }
 
     if (message.role === "user") {
       input.push({
         role: "user",
+        ...(outboundItemID(message.id) === undefined ? {} : { id: outboundItemID(message.id) }),
         content: yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, extension)),
       })
       continue
@@ -427,24 +472,24 @@ 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[] }>>(
-          (groups, part) => {
-            const metadata = part.providerMetadata?.[providerMetadataKey]
-            const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined
-            const group = groups.at(-1)
-            if (group && group.phase === phase) group.parts.push(part)
-            else groups.push({ phase, parts: [part] })
-            return groups
-          },
-          [],
-        )
+        const groups = content.reduce<
+          Array<{ phase: MessagePhase | null | undefined; itemId: string | undefined; parts: TextPart[] }>
+        >((groups, part) => {
+          const metadata = part.providerMetadata?.[providerMetadataKey]
+          const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined
+          const itemId = metadataItemID(part, providerMetadataKey)
+          const group = groups.at(-1)
+          if (group && group.phase === phase && group.itemId === itemId) group.parts.push(part)
+          else groups.push({ phase, itemId, parts: [part] })
+          return groups
+        }, [])
         input.push(
           ...groups.map((group) => ({
             role: "assistant" as const,
+            ...(outboundItemID(group.itemId) === undefined ? {} : { id: outboundItemID(group.itemId) }),
             content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
             ...(group.phase === undefined ? {} : { phase: group.phase }),
           })),
@@ -460,11 +505,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)
@@ -474,6 +514,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
           }
           const replay = {
             type: reasoning.type,
+            ...(outboundItemID(reasoning.id) === undefined ? {} : { id: outboundItemID(reasoning.id) }),
             summary: reasoning.summary,
             encrypted_content: reasoning.encrypted_content,
           }
@@ -484,22 +525,24 @@ 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))
+          const providerItem = extension.lowerProviderItem?.(part, providerMetadataKey, store)
+          if (providerItem && itemID && !hostedToolItems.has(itemID)) input.push(providerItem)
+          if (!providerItem && store !== false && itemID && !hostedToolItems.has(itemID))
             input.push({ type: "item_reference", id: itemID })
-          if (store === false && part.result.type === "content") {
+          if (!providerItem && store === false && 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 (itemID) hostedToolItems.add(itemID)
           continue
         }
         return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
@@ -518,6 +561,9 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
         return yield* ProviderShared.unsupportedContent(extension.name, "tool", ["tool-result"])
       input.push({
         type: "function_call_output",
+        ...(outboundItemID(metadataItemID(part, providerMetadataKey)) === undefined
+          ? {}
+          : { id: outboundItemID(metadataItemID(part, providerMetadataKey)) }),
         call_id: part.id,
         output: yield* lowerToolResultOutput(part, request, extension),
       })
@@ -641,9 +687,9 @@ 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 lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata)
-  return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) }, events]
+  const metadata = providerMetadata(state, { itemId: id, ...(phase === undefined ? {} : { phase }) })
+  const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata, id)
+  return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta, metadata, id) }, events]
 }
 
 const onOutputTextDone = (state: ParserState, event: Event, id: string): StepResult => {
@@ -652,7 +698,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 }), id),
+    },
+    events,
+  ]
 }
 
 export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
@@ -663,7 +715,14 @@ export const onReasoningDelta = (state: ParserState, event: Event, itemID: strin
   return [
     {
       ...state,
-      lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, id, event.delta),
+      lifecycle: Lifecycle.reasoningDelta(
+        state.lifecycle,
+        events,
+        id,
+        event.delta,
+        providerMetadata(state, { itemId: itemID }),
+        itemID,
+      ),
     },
     events,
   ]
@@ -705,7 +764,13 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
     return [
       {
         ...state,
-        lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, `${item.id}:0`, reasoningMetadata(state, item)),
+        lifecycle: Lifecycle.reasoningStart(
+          state.lifecycle,
+          events,
+          `${item.id}:0`,
+          reasoningMetadata(state, item),
+          item.id,
+        ),
         reasoningItems: {
           ...state.reasoningItems,
           [item.id]: { encryptedContent: item.encrypted_content, summaryParts: { 0: "active" } },
@@ -724,6 +789,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
       lifecycle,
       tools: ToolStream.start(state.tools, item.id, {
         id: item.call_id ?? item.id,
+        itemId: item.id,
         name: item.name ?? "",
         input: item.arguments ?? "",
         providerMetadata: metadata,
@@ -731,7 +797,12 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
     },
     [
       ...events,
-      LLMEvent.toolInputStart({ id: item.call_id ?? item.id, name: item.name ?? "", providerMetadata: metadata }),
+      LLMEvent.toolInputStart({
+        id: item.call_id ?? item.id,
+        itemId: item.id,
+        name: item.name ?? "",
+        providerMetadata: metadata,
+      }),
     ],
   ]
 }
@@ -750,6 +821,7 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
           events,
           `${event.item_id}:0`,
           providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: null }),
+          event.item_id,
         ),
         reasoningItems: {
           ...state.reasoningItems,
@@ -770,6 +842,7 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
           events,
           `${event.item_id}:${entry[0]}`,
           providerMetadata(state, { itemId: event.item_id }),
+          event.item_id,
         ),
       state.lifecycle,
     )
@@ -781,6 +854,7 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
         events,
         `${event.item_id}:${event.summary_index}`,
         providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null }),
+        event.item_id,
       ),
       reasoningItems: {
         ...state.reasoningItems,
@@ -816,6 +890,7 @@ const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResul
               events,
               `${event.item_id}:${event.summary_index}`,
               providerMetadata(state, { itemId: event.item_id }),
+              event.item_id,
             )
           : state.lifecycle,
       reasoningItems: {
@@ -870,7 +945,8 @@ 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 }) }),
+          item.id,
         ),
         messageItems,
         messagePhases,
@@ -881,9 +957,15 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
 
   if (item.type === "function_call") {
     if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
+    const metadata = providerMetadata(state, { itemId: item.id })
     const tools = state.tools[item.id]
       ? state.tools
-      : ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name })
+      : ToolStream.start(state.tools, item.id, {
+          id: item.call_id,
+          itemId: item.id,
+          name: item.name,
+          providerMetadata: metadata,
+        })
     const result =
       item.arguments === undefined
         ? yield* ToolStream.finish(state.id, tools, item.id)
@@ -913,7 +995,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
       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]}`, metadata),
+          (lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, metadata, item.id),
           state.lifecycle,
         )
       const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems
@@ -921,12 +1003,12 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
     }
     if (!state.lifecycle.reasoning.has(item.id)) {
       const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
-      events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
-      events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata }))
+      events.push(LLMEvent.reasoningStart({ id: item.id, itemId: item.id, providerMetadata: metadata }))
+      events.push(LLMEvent.reasoningEnd({ id: item.id, itemId: item.id, providerMetadata: metadata }))
       return [{ ...state, lifecycle }, events] satisfies StepResult
     }
     return [
-      { ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata) },
+      { ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata, item.id) },
       events,
     ] satisfies StepResult
   }

+ 34 - 4
packages/ai/src/protocols/openai-responses.ts

@@ -4,7 +4,7 @@ import { Auth } from "../route/auth"
 import { Endpoint } from "../route/endpoint"
 import { Protocol } from "../route/protocol"
 import { HttpTransport, WebSocketTransport } from "../route/transport"
-import { LLMEvent, LLMRequest, type JsonSchema, type ToolDefinition } from "../schema"
+import { LLMEvent, LLMRequest, ResponseItemID, type JsonSchema, type ToolDefinition } from "../schema"
 import { OpenResponses } from "./open-responses"
 import { optionalArray, ProviderShared } from "./shared"
 import { Lifecycle } from "./utils/lifecycle"
@@ -38,10 +38,14 @@ const OpenAIResponsesToolChoice = Schema.Union([
 const OpenAIResponsesInputItem = Schema.Union([
   Schema.Struct({
     role: Schema.tag("assistant"),
+    id: Schema.optionalKey(Schema.String),
     content: Schema.Array(Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String })),
     phase: Schema.optionalKey(Schema.NullOr(OpenResponses.MessagePhase)),
   }),
   OpenResponses.InputItem,
+  Schema.StructWithRest(Schema.Struct({ type: Schema.String, id: Schema.optionalKey(Schema.String) }), [
+    Schema.Record(Schema.String, Schema.Unknown),
+  ]),
 ])
 
 const OpenAIResponsesCoreFields = {
@@ -80,6 +84,26 @@ const extension = {
       mime_type: media.mime,
     }
   },
+  lowerProviderItem: (part, providerMetadataKey, store) => {
+    const metadata = part.providerMetadata?.[providerMetadataKey]
+    if (!ProviderShared.isRecord(metadata) || !ProviderShared.isRecord(metadata.item)) return undefined
+    if (typeof metadata.item.type !== "string") return undefined
+    const id = typeof metadata.item.id === "string" ? metadata.item.id : undefined
+    // The public API requires stored state to replay image-generation items. In
+    // stateless mode, lower the generated file through the existing user-image fallback.
+    if (metadata.item.type === "image_generation_call" && store === false) return undefined
+    if (metadata.item.type === "image_generation_call")
+      return {
+        type: metadata.item.type,
+        ...(id === undefined || !ResponseItemID.isPrefixed(id) ? {} : { id }),
+        ...(typeof metadata.item.status === "string" ? { status: metadata.item.status } : {}),
+        ...(typeof metadata.item.revised_prompt === "string" ? { revised_prompt: metadata.item.revised_prompt } : {}),
+        ...(typeof metadata.item.result === "string" ? { result: metadata.item.result } : {}),
+      }
+    const item: Record<string, unknown> & { type: string } = { ...metadata.item, type: metadata.item.type }
+    if (id !== undefined && !ResponseItemID.isPrefixed(id)) delete item.id
+    return item
+  },
 } satisfies OpenResponses.Extension
 
 const nativeImageToolInput = (tool: ToolDefinition) => {
@@ -195,23 +219,29 @@ 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,
+    item.type === "image_generation_call" ? { itemId: item.id } : { itemId: item.id, item },
+  )
   const events: LLMEvent[] = []
   const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
   events.push(
     LLMEvent.toolCall({
       id: item.id,
+      itemId: item.id,
       name: tool.name,
       input: tool.input(item),
       providerExecuted: true,
-      providerMetadata,
+      providerMetadata: callMetadata,
     }),
     LLMEvent.toolResult({
       id: item.id,
+      itemId: item.id,
       name: tool.name,
       result: yield* hostedToolResult(item),
       providerExecuted: true,
-      providerMetadata,
+      providerMetadata: resultMetadata,
     }),
   )
   return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult

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

@@ -1,4 +1,10 @@
-import { LLMEvent, type FinishReasonDetails, type ProviderMetadata, type Usage } from "../../schema"
+import {
+  LLMEvent,
+  type FinishReasonDetails,
+  type ProviderMetadata,
+  type ResponseItemID,
+  type Usage,
+} from "../../schema"
 
 export interface State {
   readonly stepStarted: boolean
@@ -14,16 +20,29 @@ export const stepStart = (state: State, events: LLMEvent[]): State => {
   return { ...state, stepStarted: true }
 }
 
-export const textStart = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
+export const textStart = (
+  state: State,
+  events: LLMEvent[],
+  id: string,
+  providerMetadata?: ProviderMetadata,
+  itemId?: ResponseItemID,
+): State => {
   if (state.text.has(id)) return state
   const stepped = stepStart(state, events)
-  events.push(LLMEvent.textStart({ id, providerMetadata }))
+  events.push(LLMEvent.textStart({ id, ...(itemId === undefined ? {} : { itemId }), providerMetadata }))
   return { ...stepped, text: new Set([...stepped.text, id]) }
 }
 
-export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
-  const started = textStart(state, events, id)
-  events.push(LLMEvent.textDelta({ id, text }))
+export const textDelta = (
+  state: State,
+  events: LLMEvent[],
+  id: string,
+  text: string,
+  providerMetadata?: ProviderMetadata,
+  itemId?: ResponseItemID,
+): State => {
+  const started = textStart(state, events, id, providerMetadata, itemId)
+  events.push(LLMEvent.textDelta({ id, ...(itemId === undefined ? {} : { itemId }), text, providerMetadata }))
   return started
 }
 
@@ -32,10 +51,11 @@ export const reasoningStart = (
   events: LLMEvent[],
   id: string,
   providerMetadata?: ProviderMetadata,
+  itemId?: ResponseItemID,
 ): State => {
   if (state.reasoning.has(id)) return state
   const stepped = stepStart(state, events)
-  events.push(LLMEvent.reasoningStart({ id, providerMetadata }))
+  events.push(LLMEvent.reasoningStart({ id, ...(itemId === undefined ? {} : { itemId }), providerMetadata }))
   return { ...stepped, reasoning: new Set([...stepped.reasoning, id]) }
 }
 
@@ -45,9 +65,10 @@ export const reasoningDelta = (
   id: string,
   text: string,
   providerMetadata?: ProviderMetadata,
+  itemId?: ResponseItemID,
 ): State => {
-  const started = reasoningStart(state, events, id, providerMetadata)
-  events.push(LLMEvent.reasoningDelta({ id, text, providerMetadata }))
+  const started = reasoningStart(state, events, id, providerMetadata, itemId)
+  events.push(LLMEvent.reasoningDelta({ id, ...(itemId === undefined ? {} : { itemId }), text, providerMetadata }))
   return started
 }
 
@@ -56,19 +77,26 @@ export const reasoningEnd = (
   events: LLMEvent[],
   id: string,
   providerMetadata?: ProviderMetadata,
+  itemId?: ResponseItemID,
 ): State => {
   if (!state.reasoning.has(id)) return state
   const stepped = stepStart(state, events)
-  events.push(LLMEvent.reasoningEnd({ id, providerMetadata }))
+  events.push(LLMEvent.reasoningEnd({ id, ...(itemId === undefined ? {} : { itemId }), providerMetadata }))
   const reasoning = new Set(stepped.reasoning)
   reasoning.delete(id)
   return { ...stepped, reasoning }
 }
 
-export const textEnd = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
+export const textEnd = (
+  state: State,
+  events: LLMEvent[],
+  id: string,
+  providerMetadata?: ProviderMetadata,
+  itemId?: ResponseItemID,
+): State => {
   if (!state.text.has(id)) return state
   const stepped = stepStart(state, events)
-  events.push(LLMEvent.textEnd({ id, providerMetadata }))
+  events.push(LLMEvent.textEnd({ id, ...(itemId === undefined ? {} : { itemId }), providerMetadata }))
   const text = new Set(stepped.text)
   text.delete(id)
   return { ...stepped, text }

+ 23 - 2
packages/ai/src/protocols/utils/tool-stream.ts

@@ -1,5 +1,12 @@
 import { Effect } from "effect"
-import { AIError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema"
+import {
+  AIError,
+  LLMEvent,
+  type ProviderMetadata,
+  type ResponseItemID,
+  type ToolCall,
+  type ToolInputError,
+} from "../../schema"
 import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
 
 type StreamKey = string | number
@@ -10,6 +17,7 @@ type StreamKey = string | number
  * so far, not the parsed object.
  */
 export interface PendingTool extends ToolAccumulator {
+  readonly itemId?: ResponseItemID
   readonly providerExecuted?: boolean
   readonly providerMetadata?: ProviderMetadata
 }
@@ -52,6 +60,7 @@ const withoutTool = <K extends StreamKey>(tools: State<K>, key: K): State<K> =>
 const inputStart = (tool: PendingTool) =>
   LLMEvent.toolInputStart({
     id: tool.id,
+    ...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
     name: tool.name,
     providerExecuted: tool.providerExecuted ? true : undefined,
     providerMetadata: tool.providerMetadata,
@@ -60,6 +69,7 @@ const inputStart = (tool: PendingTool) =>
 const inputDelta = (tool: PendingTool, text: string) =>
   LLMEvent.toolInputDelta({
     id: tool.id,
+    ...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
     name: tool.name,
     text,
   })
@@ -70,6 +80,7 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
     Effect.map((input): ToolCall | ToolInputError =>
       LLMEvent.toolCall({
         id: tool.id,
+        ...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
         name: tool.name,
         input,
         providerExecuted: tool.providerExecuted ? true : undefined,
@@ -82,6 +93,7 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
         : Effect.succeed(
             LLMEvent.toolInputError({
               id: tool.id,
+              ...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
               name: tool.name,
               raw,
             }),
@@ -93,7 +105,15 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
 const finishEvents = (tool: PendingTool, event: ToolCall | ToolInputError): ReadonlyArray<LLMEvent> =>
   event.type === "tool-input-error"
     ? [event]
-    : [LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }), event]
+    : [
+        LLMEvent.toolInputEnd({
+          id: tool.id,
+          ...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
+          name: tool.name,
+          providerMetadata: tool.providerMetadata,
+        }),
+        event,
+      ]
 
 /** Store the updated tool and produce the optional public delta event. */
 const appendTool = <K extends StreamKey>(
@@ -148,6 +168,7 @@ export const appendOrStart = <K extends StreamKey>(
     id,
     name,
     input: `${current?.input ?? ""}${delta.text}`,
+    itemId: current?.itemId,
     providerExecuted: current?.providerExecuted,
     providerMetadata: current?.providerMetadata,
   }

+ 72 - 24
packages/ai/src/schema/events.ts

@@ -1,5 +1,5 @@
 import { Schema } from "effect"
-import { ContentBlockID, FinishReason, ProviderMetadata, ToolCallID } from "./ids"
+import { ContentBlockID, FinishReason, ProviderMetadata, ResponseItemID, ToolCallID } from "./ids"
 import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages"
 import { ProviderFailureClassification } from "./errors"
 
@@ -84,6 +84,7 @@ export type StepStart = Schema.Schema.Type<typeof StepStart>
 export const TextStart = Schema.Struct({
   type: Schema.tag("text-start"),
   id: ContentBlockID,
+  itemId: Schema.optional(ResponseItemID),
   providerMetadata: Schema.optional(ProviderMetadata),
 }).annotate({ identifier: "LLM.Event.TextStart" })
 export type TextStart = Schema.Schema.Type<typeof TextStart>
@@ -92,6 +93,7 @@ export const TextDelta = Schema.Struct({
   type: Schema.tag("text-delta"),
   id: ContentBlockID,
   text: Schema.String,
+  itemId: Schema.optional(ResponseItemID),
   providerMetadata: Schema.optional(ProviderMetadata),
 }).annotate({ identifier: "LLM.Event.TextDelta" })
 export type TextDelta = Schema.Schema.Type<typeof TextDelta>
@@ -99,6 +101,7 @@ export type TextDelta = Schema.Schema.Type<typeof TextDelta>
 export const TextEnd = Schema.Struct({
   type: Schema.tag("text-end"),
   id: ContentBlockID,
+  itemId: Schema.optional(ResponseItemID),
   providerMetadata: Schema.optional(ProviderMetadata),
 }).annotate({ identifier: "LLM.Event.TextEnd" })
 export type TextEnd = Schema.Schema.Type<typeof TextEnd>
@@ -106,6 +109,7 @@ export type TextEnd = Schema.Schema.Type<typeof TextEnd>
 export const ReasoningStart = Schema.Struct({
   type: Schema.tag("reasoning-start"),
   id: ContentBlockID,
+  itemId: Schema.optional(ResponseItemID),
   providerMetadata: Schema.optional(ProviderMetadata),
 }).annotate({ identifier: "LLM.Event.ReasoningStart" })
 export type ReasoningStart = Schema.Schema.Type<typeof ReasoningStart>
@@ -114,6 +118,7 @@ export const ReasoningDelta = Schema.Struct({
   type: Schema.tag("reasoning-delta"),
   id: ContentBlockID,
   text: Schema.String,
+  itemId: Schema.optional(ResponseItemID),
   providerMetadata: Schema.optional(ProviderMetadata),
 }).annotate({ identifier: "LLM.Event.ReasoningDelta" })
 export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
@@ -121,6 +126,7 @@ export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
 export const ReasoningEnd = Schema.Struct({
   type: Schema.tag("reasoning-end"),
   id: ContentBlockID,
+  itemId: Schema.optional(ResponseItemID),
   providerMetadata: Schema.optional(ProviderMetadata),
 }).annotate({ identifier: "LLM.Event.ReasoningEnd" })
 export type ReasoningEnd = Schema.Schema.Type<typeof ReasoningEnd>
@@ -129,6 +135,7 @@ export const ToolInputStart = Schema.Struct({
   type: Schema.tag("tool-input-start"),
   id: ToolCallID,
   name: Schema.String,
+  itemId: Schema.optional(ResponseItemID),
   providerExecuted: Schema.optional(Schema.Boolean),
   providerMetadata: Schema.optional(ProviderMetadata),
 }).annotate({ identifier: "LLM.Event.ToolInputStart" })
@@ -137,6 +144,7 @@ export type ToolInputStart = Schema.Schema.Type<typeof ToolInputStart>
 export const ToolInputDelta = Schema.Struct({
   type: Schema.tag("tool-input-delta"),
   id: ToolCallID,
+  itemId: Schema.optional(ResponseItemID),
   name: Schema.String,
   text: Schema.String,
 }).annotate({ identifier: "LLM.Event.ToolInputDelta" })
@@ -146,6 +154,7 @@ export const ToolInputEnd = Schema.Struct({
   type: Schema.tag("tool-input-end"),
   id: ToolCallID,
   name: Schema.String,
+  itemId: Schema.optional(ResponseItemID),
   providerMetadata: Schema.optional(ProviderMetadata),
 }).annotate({ identifier: "LLM.Event.ToolInputEnd" })
 export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
@@ -154,6 +163,7 @@ export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
 export const ToolInputError = Schema.Struct({
   type: Schema.tag("tool-input-error"),
   id: ToolCallID,
+  itemId: Schema.optional(ResponseItemID),
   name: Schema.String,
   raw: Schema.String,
 }).annotate({ identifier: "LLM.Event.ToolInputError" })
@@ -162,6 +172,7 @@ export type ToolInputError = Schema.Schema.Type<typeof ToolInputError>
 export const ToolCall = Schema.Struct({
   type: Schema.tag("tool-call"),
   id: ToolCallID,
+  itemId: Schema.optional(ResponseItemID),
   name: Schema.String,
   input: Schema.Unknown,
   providerExecuted: Schema.optional(Schema.Boolean),
@@ -172,6 +183,7 @@ export type ToolCall = Schema.Schema.Type<typeof ToolCall>
 export const ToolResult = Schema.Struct({
   type: Schema.tag("tool-result"),
   id: ToolCallID,
+  itemId: Schema.optional(ResponseItemID),
   name: Schema.String,
   result: ToolResultValue,
   output: Schema.optional(ToolOutput),
@@ -183,6 +195,7 @@ export type ToolResult = Schema.Schema.Type<typeof ToolResult>
 export const ToolError = Schema.Struct({
   type: Schema.tag("tool-error"),
   id: ToolCallID,
+  itemId: Schema.optional(ResponseItemID),
   name: Schema.String,
   message: Schema.String,
   error: Schema.optional(Schema.Defect()),
@@ -334,12 +347,14 @@ const responseUsage = (events: ReadonlyArray<LLMEvent>) =>
 interface ContentAssembly {
   readonly contentIndex: number
   readonly text: string
+  readonly itemId?: ResponseItemID
   readonly providerMetadata?: ProviderMetadata
 }
 
 interface ToolInputAssembly {
   readonly name: string
   readonly text: string
+  readonly itemId?: ResponseItemID
   readonly providerMetadata?: ProviderMetadata
 }
 
@@ -385,11 +400,27 @@ const appendEvent = (state: ResponseState, event: LLMEvent): ResponseState => {
   }
 }
 
-const textContent = (text: string, providerMetadata: ProviderMetadata | undefined): ContentPart =>
-  providerMetadata === undefined ? { type: "text", text } : { type: "text", text, providerMetadata }
+const textContent = (
+  text: string,
+  itemId: ResponseItemID | undefined,
+  providerMetadata: ProviderMetadata | undefined,
+): ContentPart => ({
+  type: "text",
+  text,
+  ...(itemId === undefined ? {} : { itemId }),
+  ...(providerMetadata === undefined ? {} : { providerMetadata }),
+})
 
-const reasoningContent = (text: string, providerMetadata: ProviderMetadata | undefined): ContentPart =>
-  providerMetadata === undefined ? { type: "reasoning", text } : { type: "reasoning", text, providerMetadata }
+const reasoningContent = (
+  text: string,
+  itemId: ResponseItemID | undefined,
+  providerMetadata: ProviderMetadata | undefined,
+): ContentPart => ({
+  type: "reasoning",
+  text,
+  ...(itemId === undefined ? {} : { itemId }),
+  ...(providerMetadata === undefined ? {} : { providerMetadata }),
+})
 
 const contentWith = (state: ResponseState, content: ReadonlyArray<ContentPart>): ResponseState => ({
   ...state,
@@ -404,26 +435,32 @@ const replaceContent = (state: ResponseState, index: number, part: ContentPart)
     state.message.content.map((item, itemIndex) => (itemIndex === index ? part : item)),
   )
 
-const ensureText = (state: ResponseState, id: string, providerMetadata?: ProviderMetadata): ResponseState => {
+const ensureText = (
+  state: ResponseState,
+  id: string,
+  itemId?: ResponseItemID,
+  providerMetadata?: ProviderMetadata,
+): ResponseState => {
   if (state.textParts[id]) return state
   return {
-    ...appendContent(state, textContent("", providerMetadata)),
+    ...appendContent(state, textContent("", itemId, providerMetadata)),
     textParts: {
       ...state.textParts,
-      [id]: { contentIndex: state.message.content.length, text: "", providerMetadata },
+      [id]: { contentIndex: state.message.content.length, text: "", itemId, providerMetadata },
     },
   }
 }
 
 const reduceTextDelta = (state: ResponseState, event: TextDelta): ResponseState => {
-  const started = ensureText(state, event.id, event.providerMetadata)
+  const started = ensureText(state, event.id, event.itemId, event.providerMetadata)
   const current = started.textParts[event.id]
   if (!current) return started
   const text = current.text + event.text
+  const itemId = event.itemId ?? current.itemId
   const providerMetadata = event.providerMetadata ?? current.providerMetadata
   return {
-    ...replaceContent(started, current.contentIndex, textContent(text, providerMetadata)),
-    textParts: { ...started.textParts, [event.id]: { ...current, text, providerMetadata } },
+    ...replaceContent(started, current.contentIndex, textContent(text, itemId, providerMetadata)),
+    textParts: { ...started.textParts, [event.id]: { ...current, text, itemId, providerMetadata } },
   }
 }
 
@@ -431,32 +468,39 @@ const reduceTextEnd = (state: ResponseState, event: TextEnd): ResponseState => {
   const current = state.textParts[event.id]
   if (!current) return state
   const providerMetadata = event.providerMetadata ?? current.providerMetadata
+  const itemId = event.itemId ?? current.itemId
   return {
-    ...replaceContent(state, current.contentIndex, textContent(current.text, providerMetadata)),
-    textParts: { ...state.textParts, [event.id]: { ...current, providerMetadata } },
+    ...replaceContent(state, current.contentIndex, textContent(current.text, itemId, providerMetadata)),
+    textParts: { ...state.textParts, [event.id]: { ...current, itemId, providerMetadata } },
   }
 }
 
-const ensureReasoning = (state: ResponseState, id: string, providerMetadata?: ProviderMetadata): ResponseState => {
+const ensureReasoning = (
+  state: ResponseState,
+  id: string,
+  itemId?: ResponseItemID,
+  providerMetadata?: ProviderMetadata,
+): ResponseState => {
   if (state.reasoningParts[id]) return state
   return {
-    ...appendContent(state, reasoningContent("", providerMetadata)),
+    ...appendContent(state, reasoningContent("", itemId, providerMetadata)),
     reasoningParts: {
       ...state.reasoningParts,
-      [id]: { contentIndex: state.message.content.length, text: "", providerMetadata },
+      [id]: { contentIndex: state.message.content.length, text: "", itemId, providerMetadata },
     },
   }
 }
 
 const reduceReasoningDelta = (state: ResponseState, event: ReasoningDelta): ResponseState => {
-  const started = ensureReasoning(state, event.id, event.providerMetadata)
+  const started = ensureReasoning(state, event.id, event.itemId, event.providerMetadata)
   const current = started.reasoningParts[event.id]
   if (!current) return started
   const text = current.text + event.text
+  const itemId = event.itemId ?? current.itemId
   const providerMetadata = event.providerMetadata ?? current.providerMetadata
   return {
-    ...replaceContent(started, current.contentIndex, reasoningContent(text, providerMetadata)),
-    reasoningParts: { ...started.reasoningParts, [event.id]: { ...current, text, providerMetadata } },
+    ...replaceContent(started, current.contentIndex, reasoningContent(text, itemId, providerMetadata)),
+    reasoningParts: { ...started.reasoningParts, [event.id]: { ...current, text, itemId, providerMetadata } },
   }
 }
 
@@ -464,9 +508,10 @@ const reduceReasoningEnd = (state: ResponseState, event: ReasoningEnd): Response
   const current = state.reasoningParts[event.id]
   if (!current) return state
   const providerMetadata = event.providerMetadata ?? current.providerMetadata
+  const itemId = event.itemId ?? current.itemId
   return {
-    ...replaceContent(state, current.contentIndex, reasoningContent(current.text, providerMetadata)),
-    reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, providerMetadata } },
+    ...replaceContent(state, current.contentIndex, reasoningContent(current.text, itemId, providerMetadata)),
+    reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, itemId, providerMetadata } },
   }
 }
 
@@ -474,7 +519,7 @@ const reduceToolInputStart = (state: ResponseState, event: ToolInputStart): Resp
   ...state,
   toolInputs: {
     ...state.toolInputs,
-    [event.id]: { name: event.name, text: "", providerMetadata: event.providerMetadata },
+    [event.id]: { name: event.name, text: "", itemId: event.itemId, providerMetadata: event.providerMetadata },
   },
 })
 
@@ -495,6 +540,7 @@ const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): Response
       [event.id]: {
         ...current,
         name: event.name,
+        itemId: event.itemId ?? current.itemId,
         providerMetadata: event.providerMetadata ?? current.providerMetadata,
       },
     },
@@ -504,6 +550,7 @@ const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): Response
 const toolCallContent = (event: ToolCall): ContentPart =>
   ToolCallPart.make({
     id: event.id,
+    ...(event.itemId === undefined ? {} : { itemId: event.itemId }),
     name: event.name,
     input: event.input,
     ...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }),
@@ -513,6 +560,7 @@ const toolCallContent = (event: ToolCall): ContentPart =>
 const toolResultContent = (event: ToolResult): ContentPart =>
   ToolResultPart.make({
     id: event.id,
+    ...(event.itemId === undefined ? {} : { itemId: event.itemId }),
     name: event.name,
     result: event.result,
     ...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }),
@@ -528,13 +576,13 @@ const reduceResponseState = (state: ResponseState, event: LLMEvent): ResponseSta
   const next = appendEvent(state, event)
   switch (event.type) {
     case "text-start":
-      return ensureText(next, event.id, event.providerMetadata)
+      return ensureText(next, event.id, event.itemId, event.providerMetadata)
     case "text-delta":
       return reduceTextDelta(next, event)
     case "text-end":
       return reduceTextEnd(next, event)
     case "reasoning-start":
-      return ensureReasoning(next, event.id, event.providerMetadata)
+      return ensureReasoning(next, event.id, event.itemId, event.providerMetadata)
     case "reasoning-delta":
       return reduceReasoningDelta(next, event)
     case "reasoning-end":

+ 9 - 0
packages/ai/src/schema/ids.ts

@@ -21,6 +21,15 @@ export type ProviderID = typeof ProviderID.Type
 export const ResponseID = Schema.String
 export type ResponseID = Schema.Schema.Type<typeof ResponseID>
 
+export const ResponseItemID = Object.assign(Schema.String, {
+  create: (prefix: string): ResponseItemID => `${prefix}_${crypto.randomUUID()}`,
+  isPrefixed: (value: string) => {
+    const separator = value.indexOf("_")
+    return separator > 0 && separator < value.length - 1
+  },
+})
+export type ResponseItemID = Schema.Schema.Type<typeof ResponseItemID>
+
 export const ContentBlockID = Schema.String
 export type ContentBlockID = Schema.Schema.Type<typeof ContentBlockID>
 

+ 7 - 2
packages/ai/src/schema/messages.ts

@@ -1,6 +1,6 @@
 import { Schema } from "effect"
 import { Tool } from "@opencode-ai/schema/tool"
-import { JsonSchema, MessageRole, ProviderMetadata } from "./ids"
+import { JsonSchema, MessageRole, ProviderMetadata, ResponseItemID } from "./ids"
 import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, LanguageModelSchema, ProviderOptions } from "./options"
 import { isRecord } from "../utils/record"
 
@@ -25,6 +25,7 @@ export const SystemPart = Object.assign(systemPartSchema, {
 export const TextPart = Schema.Struct({
   type: Schema.Literal("text"),
   text: Schema.String,
+  itemId: Schema.optional(ResponseItemID),
   cache: Schema.optional(CacheHint),
   metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
   providerMetadata: Schema.optional(ProviderMetadata),
@@ -121,6 +122,7 @@ export const ToolCallPart = Object.assign(
   Schema.Struct({
     type: Schema.Literal("tool-call"),
     id: Schema.String,
+    itemId: Schema.optional(ResponseItemID),
     name: Schema.String,
     input: Schema.Unknown,
     providerExecuted: Schema.optional(Schema.Boolean),
@@ -138,6 +140,7 @@ export const ToolResultPart = Object.assign(
   Schema.Struct({
     type: Schema.Literal("tool-result"),
     id: Schema.String,
+    itemId: Schema.optional(ResponseItemID),
     name: Schema.String,
     result: ToolResultValue,
     providerExecuted: Schema.optional(Schema.Boolean),
@@ -154,6 +157,7 @@ export const ToolResultPart = Object.assign(
     ): ToolResultPart => ({
       type: "tool-result",
       id: input.id,
+      ...(input.itemId === undefined ? {} : { itemId: input.itemId }),
       name: input.name,
       result: ToolResultValue.make(input.result, input.resultType),
       providerExecuted: input.providerExecuted,
@@ -168,6 +172,7 @@ export type ToolResultPart = Schema.Schema.Type<typeof ToolResultPart>
 export const ReasoningPart = Schema.Struct({
   type: Schema.Literal("reasoning"),
   text: Schema.String,
+  itemId: Schema.optional(ResponseItemID),
   encrypted: Schema.optional(Schema.String),
   cache: Schema.optional(CacheHint),
   metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
@@ -181,7 +186,7 @@ export const ContentPart = Schema.Union([TextPart, MediaPart, ToolCallPart, Tool
 export type ContentPart = Schema.Schema.Type<typeof ContentPart>
 
 export class Message extends Schema.Class<Message>("LLM.Message")({
-  id: Schema.optional(Schema.String),
+  id: Schema.optional(ResponseItemID),
   role: MessageRole,
   content: Schema.Array(ContentPart),
   metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),

+ 10 - 2
packages/ai/src/tool-runtime.ts

@@ -1,6 +1,7 @@
 import { Effect } from "effect"
 import {
   LLMEvent,
+  ResponseItemID,
   type ToolCallPart,
   ToolFailure,
   ToolOutput,
@@ -62,6 +63,10 @@ const decodeAndExecute = (tool: AnyTool, call: ToolCallPart): Effect.Effect<Tool
 
 const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement, error?: unknown): DispatchResult => {
   const settlement = ToolResultValue.is(value) ? { result: value } : value
+  const itemId =
+    call.itemId !== undefined && call.itemId.startsWith("fc_")
+      ? `fco_${call.itemId.slice(3)}`
+      : ResponseItemID.create("fco")
   return {
     result: settlement.result,
     output: settlement.output,
@@ -70,6 +75,7 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement,
         ? [
             LLMEvent.toolError({
               id: call.id,
+              itemId,
               name: call.name,
               message: String(settlement.result.value),
               error,
@@ -77,18 +83,20 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement,
             }),
             LLMEvent.toolResult({
               id: call.id,
+              itemId,
               name: call.name,
               result: settlement.result,
-              providerMetadata: call.providerMetadata,
+              ...(call.providerMetadata === undefined ? {} : { providerMetadata: call.providerMetadata }),
             }),
           ]
         : [
             LLMEvent.toolResult({
               id: call.id,
+              itemId,
               name: call.name,
               result: settlement.result,
               output: settlement.output,
-              providerMetadata: call.providerMetadata,
+              ...(call.providerMetadata === undefined ? {} : { providerMetadata: call.providerMetadata }),
             }),
           ],
   }

Fișier diff suprimat deoarece este prea mare
+ 7 - 2
packages/ai/test/fixtures/recordings/openai-responses-cache/reports-cached-tokens-on-identical-second-call.json


Fișier diff suprimat deoarece este prea mare
+ 2 - 2
packages/ai/test/fixtures/recordings/openai-responses-images/generates-and-edits-an-image-with-the-hosted-tool.json


+ 14 - 5
packages/ai/test/lib/tool-runtime.ts

@@ -8,7 +8,6 @@ import {
   type ProviderMetadata,
   type ToolCallPart,
   ToolResultPart,
-  type ToolResultValue,
   type Usage,
 } from "../../src/schema"
 import { type Tools, toDefinitions } from "../../src/tool"
@@ -61,9 +60,10 @@ export const runTools = <T extends Tools>(options: RunOptions<T>) =>
             ...dispatched.map(([call, dispatched]) =>
               Message.tool({
                 id: call.id,
+                itemId: dispatched.events.find(LLMEvent.is.toolResult)?.itemId,
                 name: call.name,
                 result: dispatched.result,
-                providerMetadata: call.providerMetadata,
+                providerMetadata: dispatched.events.find(LLMEvent.is.toolResult)?.providerMetadata,
               }),
             ),
           ],
@@ -89,9 +89,15 @@ const stepState = (events: ReadonlyArray<LLMEvent>) => {
 
   for (const event of events) {
     if (event.type === "text-delta" || event.type === "reasoning-delta") {
-      appendText(assistantContent, event.type === "text-delta" ? "text" : "reasoning", event.text)
+      appendText(assistantContent, event.type === "text-delta" ? "text" : "reasoning", event.text, event.itemId)
     } else if (event.type === "text-end" || event.type === "reasoning-end") {
-      appendText(assistantContent, event.type === "text-end" ? "text" : "reasoning", "", event.providerMetadata)
+      appendText(
+        assistantContent,
+        event.type === "text-end" ? "text" : "reasoning",
+        "",
+        event.itemId,
+        event.providerMetadata,
+      )
     } else if (event.type === "tool-call") {
       assistantContent.push(event)
       if (!event.providerExecuted) toolCalls.push(event)
@@ -99,6 +105,7 @@ const stepState = (events: ReadonlyArray<LLMEvent>) => {
       assistantContent.push(
         ToolResultPart.make({
           id: event.id,
+          itemId: event.itemId,
           name: event.name,
           result: event.result,
           providerExecuted: true,
@@ -118,6 +125,7 @@ const appendText = (
   content: ContentPart[],
   type: "text" | "reasoning",
   text: string,
+  itemId?: string,
   providerMetadata?: ProviderMetadata,
 ) => {
   const last = content.at(-1)
@@ -125,11 +133,12 @@ const appendText = (
     content[content.length - 1] = {
       ...last,
       text: `${last.text}${text}`,
+      itemId: itemId ?? last.itemId,
       providerMetadata: providerMetadata ?? last.providerMetadata,
     }
     return
   }
-  content.push({ type, text, providerMetadata })
+  content.push({ type, text, itemId, providerMetadata })
 }
 
 const addUsage = (left: Usage | undefined, right: Usage | undefined): Usage | undefined => {

+ 3 - 1
packages/ai/test/provider/openai-compatible-responses.test.ts

@@ -45,7 +45,7 @@ describe("Open Responses-compatible route", () => {
           },
         },
       })
-      expect(prepared.body).toEqual({
+      expect(prepared.body).toMatchObject({
         model: "example-model",
         input: [
           { role: "system", content: "You are concise." },
@@ -53,6 +53,8 @@ describe("Open Responses-compatible route", () => {
         ],
         stream: true,
       })
+      expect(prepared.body.input[0]).not.toHaveProperty("id")
+      expect(prepared.body.input[1]?.id).toStartWith("msg_")
     }),
   )
 

+ 216 - 39
packages/ai/test/provider/openai-responses.test.ts

@@ -54,21 +54,36 @@ const expectToolOutput = (body: OpenAIResponses.OpenAIResponsesBody): OpenAITool
   return output!
 }
 
+const generatedResponseItemID =
+  /^(?:(?:msg|fc|fco|rs)_[0-9a-f-]{36}|(?:msg|fc|fco|rs)_msg_[0-9a-f-]{36}_\d+|msg_req_[0-9a-f-]{36}_system)$/i
+const withoutGeneratedResponseItemIDs = (value: unknown): unknown => {
+  if (Array.isArray(value)) return value.map(withoutGeneratedResponseItemIDs)
+  if (typeof value !== "object" || value === null) return value
+  return Object.fromEntries(
+    Object.entries(value)
+      .filter(([key, item]) => !(key === "id" && typeof item === "string" && generatedResponseItemID.test(item)))
+      .map(([key, item]) => [key, withoutGeneratedResponseItemIDs(item)]),
+  )
+}
+
 describe("OpenAI Responses route", () => {
   it.effect("prepares OpenAI Responses target", () =>
     Effect.gen(function* () {
       const prepared = yield* compileRequest(request)
 
-      expect(prepared.body).toEqual({
+      expect(withoutGeneratedResponseItemIDs(prepared.body)).toEqual({
         model: "gpt-4.1-mini",
         input: [
-          { role: "system", content: "You are concise." },
+          { role: "system", id: "msg_req_1_system", content: "You are concise." },
           { role: "user", content: [{ type: "input_text", text: "Say hello." }] },
         ],
         store: false,
         stream: true,
         max_output_tokens: 20,
         temperature: 0,
+        tool_choice: undefined,
+        tools: undefined,
+        top_p: undefined,
       })
     }),
   )
@@ -203,7 +218,7 @@ describe("OpenAI Responses route", () => {
         }),
       )
 
-      expect(prepared.body.input).toEqual([
+      expect(withoutGeneratedResponseItemIDs(prepared.body.input)).toEqual([
         {
           role: "user",
           content: [
@@ -279,7 +294,7 @@ describe("OpenAI Responses route", () => {
       expect(opened).toEqual([{ url: "wss://api.openai.test/v1/responses", authorization: "Bearer test" }])
       expect(closed).toBe(true)
       expect(sent).toHaveLength(1)
-      expect(JSON.parse(sent[0])).toEqual({
+      expect(withoutGeneratedResponseItemIDs(JSON.parse(sent[0]))).toEqual({
         type: "response.create",
         model: "gpt-4.1-mini",
         input: [{ role: "user", content: [{ type: "input_text", text: "Say hello." }] }],
@@ -329,7 +344,7 @@ describe("OpenAI Responses route", () => {
       yield* LLMClient.generate(
         LLMRequest.update(request, {
           model: Azure.configure({
-            baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
+            baseURL: "https://opencode-test.openai.azure.com/openai/",
             apiKey: "azure-key",
             headers: { authorization: "Bearer stale" },
           }).responses("gpt-4.1-mini"),
@@ -410,7 +425,7 @@ describe("OpenAI Responses route", () => {
         }),
       )
 
-      expect(prepared.body).toEqual({
+      expect(prepared.body).toMatchObject({
         model: "gpt-4.1-mini",
         input: [
           { role: "user", content: [{ type: "input_text", text: "What is the weather?" }] },
@@ -425,6 +440,61 @@ describe("OpenAI Responses route", () => {
         tools: undefined,
         top_p: undefined,
       })
+      const call = prepared.body.input.find((item) => "type" in item && item.type === "function_call")
+      const output = prepared.body.input.find((item) => "type" in item && item.type === "function_call_output")
+      expect(call?.id).toStartWith("fc_")
+      expect(output?.id).toStartWith("fco_")
+      expect(call?.id).not.toBe(output?.id)
+    }),
+  )
+
+  it.effect("generates canonical response item id prefixes", () =>
+    Effect.sync(() => {
+      const canonical = LLM.request({
+        model,
+        messages: [
+          Message.assistant([
+            { type: "text", text: "Working." },
+            { type: "reasoning", text: "Thinking." },
+            ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }),
+          ]),
+          Message.tool({ id: "call_1", name: "lookup", result: "done" }),
+        ],
+      })
+
+      expect(canonical.messages[0]?.id).toStartWith("msg_")
+      expect(canonical.messages[1]?.id).toStartWith("msg_")
+      expect(canonical.messages[0]?.content[0]).toMatchObject({ itemId: expect.stringMatching(/^msg_/) })
+      expect(canonical.messages[0]?.content[1]).toMatchObject({ itemId: expect.stringMatching(/^rs_/) })
+      expect(canonical.messages[0]?.content[2]).toMatchObject({ itemId: expect.stringMatching(/^fc_/) })
+      expect(canonical.messages[1]?.content[0]).toMatchObject({ itemId: expect.stringMatching(/^fco_/) })
+    }),
+  )
+
+  it.effect("keeps unprefixed item ids canonical but omits them outbound", () =>
+    Effect.gen(function* () {
+      const canonical = LLM.request({
+        model,
+        messages: [
+          Message.assistant([
+            { type: "text", text: "Calling.", itemId: "plain-text" },
+            ToolCallPart.make({ id: "call_1", itemId: "plain-call", name: "lookup", input: {} }),
+          ]),
+          Message.tool({ id: "call_1", itemId: "plain-output", name: "lookup", result: "done" }),
+        ],
+      })
+      const prepared = yield* compileRequest(canonical)
+
+      expect(canonical.messages[0]?.content.map((part) => (part.type === "media" ? undefined : part.itemId))).toEqual([
+        "plain-text",
+        "plain-call",
+      ])
+      expect(canonical.messages[1]?.content[0]).toMatchObject({ itemId: "plain-output" })
+      expect(withoutGeneratedResponseItemIDs(prepared.body.input)).toEqual([
+        { role: "assistant", content: [{ type: "output_text", text: "Calling." }] },
+        { type: "function_call", call_id: "call_1", name: "lookup", arguments: "{}" },
+        { type: "function_call_output", call_id: "call_1", output: '"done"' },
+      ])
     }),
   )
 
@@ -864,9 +934,21 @@ 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-delta", id: "msg_1", text: "Hello" },
-        { type: "text-delta", id: "msg_1", text: "!" },
+        { type: "text-start", id: "msg_1", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
+        {
+          type: "text-delta",
+          id: "msg_1",
+          itemId: "msg_1",
+          text: "Hello",
+          providerMetadata: { openai: { itemId: "msg_1" } },
+        },
+        {
+          type: "text-delta",
+          id: "msg_1",
+          itemId: "msg_1",
+          text: "!",
+          providerMetadata: { openai: { itemId: "msg_1" } },
+        },
         { type: "text-end", id: "msg_1" },
         {
           type: "step-finish",
@@ -923,34 +1005,40 @@ describe("OpenAI Responses route", () => {
         {
           type: "text",
           text: "Checking.",
-          providerMetadata: { openai: { phase: "commentary" } },
+          itemId: "msg_commentary",
+          providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
         },
         {
           type: "text",
           text: "Finished.",
-          providerMetadata: { openai: { phase: "final_answer" } },
+          itemId: "msg_final",
+          providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } },
         },
         {
           type: "text",
           text: "Unclassified.",
-          providerMetadata: { openai: { phase: null } },
+          itemId: "msg_null",
+          providerMetadata: { openai: { itemId: "msg_null", phase: null } },
         },
       ])
 
       const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
-      expect(prepared.body.input).toEqual([
+      expect(withoutGeneratedResponseItemIDs(prepared.body.input)).toEqual([
         {
           role: "assistant",
+          id: "msg_commentary",
           content: [{ type: "output_text", text: "Checking." }],
           phase: "commentary",
         },
         {
           role: "assistant",
+          id: "msg_final",
           content: [{ type: "output_text", text: "Finished." }],
           phase: "final_answer",
         },
         {
           role: "assistant",
+          id: "msg_null",
           content: [{ type: "output_text", text: "Unclassified." }],
           phase: null,
         },
@@ -1043,12 +1131,24 @@ describe("OpenAI Responses route", () => {
       )
 
       expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([
-        { type: "text-start", id: "msg_1" },
-        { type: "text-delta", id: "msg_1", text: "First" },
-        { type: "text-end", id: "msg_1" },
-        { type: "text-start", id: "msg_2" },
-        { type: "text-delta", id: "msg_2", text: "Second" },
-        { type: "text-end", id: "msg_2" },
+        { type: "text-start", id: "msg_1", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
+        {
+          type: "text-delta",
+          id: "msg_1",
+          itemId: "msg_1",
+          text: "First",
+          providerMetadata: { openai: { itemId: "msg_1" } },
+        },
+        { type: "text-end", id: "msg_1", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
+        { type: "text-start", id: "msg_2", itemId: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
+        {
+          type: "text-delta",
+          id: "msg_2",
+          itemId: "msg_2",
+          text: "Second",
+          providerMetadata: { openai: { itemId: "msg_2" } },
+        },
+        { type: "text-end", id: "msg_2", itemId: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
       ])
     }),
   )
@@ -1068,9 +1168,15 @@ describe("OpenAI Responses route", () => {
       expect(response.text).toBe("Hello")
       expect(response.events).toMatchObject([
         { 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: "reasoning-start", id: "rs_1", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
+        {
+          type: "reasoning-delta",
+          id: "rs_1",
+          itemId: "rs_1",
+          text: "thinking",
+          providerMetadata: { openai: { itemId: "rs_1" } },
+        },
+        { type: "text-start", id: "msg_1", itemId: "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" },
@@ -1079,8 +1185,8 @@ 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: "reasoning", text: "thinking", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
+        { type: "text", text: "Hello", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
       ])
     }),
   )
@@ -1111,6 +1217,7 @@ describe("OpenAI Responses route", () => {
         expect.objectContaining({
           type: "reasoning-end",
           id: "rs_1",
+          itemId: "rs_1",
           providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
         }),
       )
@@ -1151,19 +1258,34 @@ describe("OpenAI Responses route", () => {
         {
           type: "reasoning-start",
           id: "rs_1:0",
+          itemId: "rs_1",
           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-delta",
+          id: "rs_1:0",
+          itemId: "rs_1",
+          text: "First",
+          providerMetadata: { openai: { itemId: "rs_1" } },
+        },
+        { type: "reasoning-end", id: "rs_1:0", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
         {
           type: "reasoning-start",
           id: "rs_1:1",
+          itemId: "rs_1",
           providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
         },
-        { type: "reasoning-delta", id: "rs_1:1", text: "Second" },
+        {
+          type: "reasoning-delta",
+          id: "rs_1:1",
+          itemId: "rs_1",
+          text: "Second",
+          providerMetadata: { openai: { itemId: "rs_1" } },
+        },
         {
           type: "reasoning-end",
           id: "rs_1:1",
+          itemId: "rs_1",
           providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
         },
         { type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
@@ -1201,8 +1323,8 @@ describe("OpenAI Responses route", () => {
       )
 
       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" } } },
+        { type: "reasoning-end", id: "rs_1:0", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
+        { type: "reasoning-end", id: "rs_1:1", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
       ])
     }),
   )
@@ -1250,7 +1372,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." },
@@ -1293,10 +1415,11 @@ describe("OpenAI Responses route", () => {
         }),
       )
 
-      expect(prepared.body.input).toEqual([
+      expect(withoutGeneratedResponseItemIDs(prepared.body.input)).toEqual([
         { role: "assistant", content: [{ type: "output_text", text: "Before." }] },
         {
           type: "reasoning",
+          id: "rs_1",
           encrypted_content: "encrypted-state",
           summary: [{ type: "summary_text", text: "Checked order." }],
         },
@@ -1305,7 +1428,7 @@ describe("OpenAI Responses route", () => {
     }),
   )
 
-  it.effect("references stored reasoning items by id", () =>
+  it.effect("replays complete stored reasoning items with their id", () =>
     Effect.gen(function* () {
       const prepared = yield* compileRequest(
         LLM.request({
@@ -1323,7 +1446,14 @@ describe("OpenAI Responses route", () => {
         }),
       )
 
-      expect(prepared.body.input).toEqual([{ type: "item_reference", id: "rs_1" }])
+      expect(withoutGeneratedResponseItemIDs(prepared.body.input)).toEqual([
+        {
+          type: "reasoning",
+          id: "rs_1",
+          summary: [{ type: "summary_text", text: "Checked the previous diff." }],
+          encrypted_content: undefined,
+        },
+      ])
     }),
   )
 
@@ -1356,7 +1486,7 @@ describe("OpenAI Responses route", () => {
         }),
       )
 
-      expect(prepared.body.input).toEqual([
+      expect(withoutGeneratedResponseItemIDs(prepared.body.input)).toEqual([
         { type: "item_reference", id: "ws_1" },
         { role: "user", content: [{ type: "input_text", text: "Continue." }] },
       ])
@@ -1397,7 +1527,7 @@ describe("OpenAI Responses route", () => {
       )
 
       expect(prepared.body.store).toBe(false)
-      expect(prepared.body.input).toEqual([
+      expect(withoutGeneratedResponseItemIDs(prepared.body.input)).toEqual([
         { role: "user", content: [{ type: "input_text", text: "Generate a black triangle." }] },
         { role: "user", content: [{ type: "input_image", image_url: "data:image/png;base64,AQID" }] },
         { role: "user", content: [{ type: "input_text", text: "Make it blue." }] },
@@ -1429,9 +1559,10 @@ describe("OpenAI Responses route", () => {
         }),
       )
 
-      expect(prepared.body.input).toEqual([
+      expect(withoutGeneratedResponseItemIDs(prepared.body.input)).toEqual([
         {
           type: "reasoning",
+          id: "rs_1",
           encrypted_content: "encrypted-state",
           summary: [
             { type: "summary_text", text: "First" },
@@ -1511,6 +1642,7 @@ describe("OpenAI Responses route", () => {
         outputTokens: 1,
         nonCachedInputTokens: 5,
         cacheReadInputTokens: undefined,
+        cacheWriteInputTokens: undefined,
         reasoningTokens: undefined,
         totalTokens: 6,
         providerMetadata: { openai: { input_tokens: 5, output_tokens: 1 } },
@@ -1521,30 +1653,35 @@ describe("OpenAI Responses route", () => {
         {
           type: "tool-input-start",
           id: "call_1",
+          itemId: "item_1",
           name: "lookup",
           providerMetadata: { openai: { itemId: "item_1" } },
         },
         {
           type: "tool-input-delta",
           id: "call_1",
+          itemId: "item_1",
           name: "lookup",
           text: '{"query"',
         },
         {
           type: "tool-input-delta",
           id: "call_1",
+          itemId: "item_1",
           name: "lookup",
           text: ':"weather"}',
         },
         {
           type: "tool-input-end",
           id: "call_1",
+          itemId: "item_1",
           name: "lookup",
           providerMetadata: { openai: { itemId: "item_1" } },
         },
         {
           type: "tool-call",
           id: "call_1",
+          itemId: "item_1",
           name: "lookup",
           input: { query: "weather" },
           providerExecuted: undefined,
@@ -1564,6 +1701,17 @@ describe("OpenAI Responses route", () => {
           usage,
         },
       ])
+      expect(response.message.content).toEqual([
+        {
+          type: "tool-call",
+          id: "call_1",
+          itemId: "item_1",
+          name: "lookup",
+          input: { query: "weather" },
+          providerExecuted: undefined,
+          providerMetadata: { openai: { itemId: "item_1" } },
+        },
+      ])
     }),
   )
 
@@ -1596,6 +1744,7 @@ describe("OpenAI Responses route", () => {
       expect(response.events.find(LLMEvent.is.toolInputError)).toEqual({
         type: "tool-input-error",
         id: "call_1",
+        itemId: "item_1",
         name: "lookup",
         raw: '{"query":"partial',
       })
@@ -1652,6 +1801,7 @@ describe("OpenAI Responses route", () => {
         {
           type: "tool-call",
           id: "ws_1",
+          itemId: "ws_1",
           name: "web_search",
           input: { type: "search", query: "effect 4" },
           providerExecuted: true,
@@ -1660,11 +1810,35 @@ describe("OpenAI Responses route", () => {
         {
           type: "tool-result",
           id: "ws_1",
+          itemId: "ws_1",
           name: "web_search",
           result: { type: "json", value: item },
           providerExecuted: true,
+          providerMetadata: { openai: { itemId: "ws_1", item } },
+          output: undefined,
+        },
+      ])
+      expect(response.message.content).toEqual([
+        {
+          type: "tool-call",
+          id: "ws_1",
+          itemId: "ws_1",
+          name: "web_search",
+          input: { type: "search", query: "effect 4" },
+          providerExecuted: true,
           providerMetadata: { openai: { itemId: "ws_1" } },
         },
+        {
+          type: "tool-result",
+          id: "ws_1",
+          itemId: "ws_1",
+          name: "web_search",
+          result: { type: "json", value: item },
+          providerExecuted: true,
+          providerMetadata: { openai: { itemId: "ws_1", item } },
+          metadata: undefined,
+          cache: undefined,
+        },
       ])
     }),
   )
@@ -1742,6 +1916,7 @@ describe("OpenAI Responses route", () => {
       expect(toolCall).toEqual({
         type: "tool-call",
         id: "ci_1",
+        itemId: "ci_1",
         name: "code_interpreter",
         input: { code: "print(1+1)", container_id: "cnt_xyz" },
         providerExecuted: true,
@@ -1751,10 +1926,12 @@ describe("OpenAI Responses route", () => {
       expect(toolResult).toEqual({
         type: "tool-result",
         id: "ci_1",
+        itemId: "ci_1",
         name: "code_interpreter",
         result: { type: "json", value: item },
         providerExecuted: true,
-        providerMetadata: { openai: { itemId: "ci_1" } },
+        providerMetadata: { openai: { itemId: "ci_1", item } },
+        output: undefined,
       })
     }),
   )
@@ -1774,7 +1951,7 @@ describe("OpenAI Responses route", () => {
         }),
       )
 
-      expect(prepared.body.input).toEqual([
+      expect(withoutGeneratedResponseItemIDs(prepared.body.input)).toEqual([
         {
           role: "user",
           content: [
@@ -1806,7 +1983,7 @@ describe("OpenAI Responses route", () => {
         }),
       )
 
-      expect(prepared.body.input).toEqual([
+      expect(withoutGeneratedResponseItemIDs(prepared.body.input)).toEqual([
         {
           role: "user",
           content: [

+ 61 - 0
packages/ai/test/recorded-test.ts

@@ -17,6 +17,66 @@ import {
 const __dirname = path.dirname(fileURLToPath(import.meta.url))
 const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
 
+const canonical = (value: unknown): unknown => {
+  if (Array.isArray(value)) return value.map(canonical)
+  if (typeof value !== "object" || value === null) return value
+  return Object.fromEntries(
+    Object.entries(value)
+      .toSorted(([left], [right]) => left.localeCompare(right))
+      .map(([key, item]) => [key, canonical(item)]),
+  )
+}
+
+const generatedItemID =
+  /^(?:(?:msg|fc|fco|rs)_[0-9a-f-]{36}|(?:msg|fc|fco|rs)_msg_[0-9a-f-]{36}_\d+|msg_req_[0-9a-f-]{36}_system)$/i
+
+const responseItemBody = (body: string, url: string, omitIDs: ReadonlySet<number> = new Set()) => {
+  try {
+    const value: unknown = JSON.parse(body)
+    if (
+      !new URL(url).pathname.endsWith("/responses") ||
+      typeof value !== "object" ||
+      value === null ||
+      !("input" in value) ||
+      !Array.isArray(value.input)
+    )
+      return canonical(value)
+    return canonical({
+      ...value,
+      input: value.input.map((item: unknown, index: number) => {
+        if (typeof item !== "object" || item === null) return item
+        const id = "id" in item ? item.id : undefined
+        if (typeof id !== "string" || (!generatedItemID.test(id) && !omitIDs.has(index))) return item
+        return Object.fromEntries(Object.entries(item).filter(([key]) => key !== "id"))
+      }),
+    })
+  } catch {
+    return body
+  }
+}
+
+const missingResponseItemIDs = (body: string) => {
+  try {
+    const value: unknown = JSON.parse(body)
+    if (typeof value !== "object" || value === null || !("input" in value) || !Array.isArray(value.input))
+      return new Set<number>()
+    return new Set(
+      value.input.flatMap((item: unknown, index: number) =>
+        typeof item === "object" && item !== null && !("id" in item) ? [index] : [],
+      ),
+    )
+  } catch {
+    return new Set<number>()
+  }
+}
+
+const responseItemMatcher: HttpRecorder.RequestMatcher = (incoming, recorded) =>
+  incoming.method === recorded.method &&
+  incoming.url === recorded.url &&
+  JSON.stringify(canonical(incoming.headers)) === JSON.stringify(canonical(recorded.headers)) &&
+  JSON.stringify(responseItemBody(incoming.body, incoming.url, missingResponseItemIDs(recorded.body))) ===
+    JSON.stringify(responseItemBody(recorded.body, recorded.url))
+
 type RecordedEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService | ImageClientService
 
 type RecordedTestsOptions = RecordedGroupOptions & {
@@ -78,6 +138,7 @@ export const recordedTests = (options: RecordedTestsOptions) =>
           HttpRecorder.layerFetch(cassette, {
             ...recorderOptions,
             directory: FIXTURES_DIR,
+            match: recorderOptions?.match ?? responseItemMatcher,
             metadata: recorderMetadata,
           }),
         ),

+ 37 - 0
packages/ai/test/response.test.ts

@@ -49,6 +49,43 @@ describe("LLMResponse reducer", () => {
     expect(state.message.content).toEqual([{ type: "text", text: "partial" }])
   })
 
+  test("assembles response item identity and provider metadata", () => {
+    const response = LLMResponse.fromEvents([
+      LLMEvent.textStart({ id: "text-block", itemId: "msg_existing" }),
+      LLMEvent.textDelta({
+        id: "text-block",
+        itemId: "msg_existing",
+        text: "Answer",
+        providerMetadata: { openai: { itemId: "msg_existing" } },
+      }),
+      LLMEvent.textEnd({ id: "text-block", itemId: "msg_existing" }),
+      LLMEvent.reasoningStart({ id: "reasoning-block", itemId: "rs_existing" }),
+      LLMEvent.reasoningDelta({
+        id: "reasoning-block",
+        itemId: "rs_existing",
+        text: "Thought",
+        providerMetadata: { openai: { itemId: "rs_existing" } },
+      }),
+      LLMEvent.reasoningEnd({ id: "reasoning-block", itemId: "rs_existing" }),
+      LLMEvent.finish({ reason: { normalized: "stop" } }),
+    ])
+
+    expect(response?.message.content).toEqual([
+      {
+        type: "text",
+        text: "Answer",
+        itemId: "msg_existing",
+        providerMetadata: { openai: { itemId: "msg_existing" } },
+      },
+      {
+        type: "reasoning",
+        text: "Thought",
+        itemId: "rs_existing",
+        providerMetadata: { openai: { itemId: "rs_existing" } },
+      },
+    ])
+  })
+
   test("does not complete ended content without a terminal finish", () => {
     const state = reduce([
       LLMEvent.textStart({ id: "t1" }),

+ 30 - 5
packages/ai/test/tool-runtime.test.ts

@@ -172,7 +172,7 @@ describe("LLMClient tools", () => {
       expect(calls).toEqual([{ id: "call_projected", parameters: { prefix: "count" }, output: { count: "2" } }])
       expect(dispatched.result).toEqual({ type: "text", value: "count:2" })
       expect(dispatched.output).toEqual({ structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] })
-      expect(dispatched.events).toEqual([
+      expect(dispatched.events).toMatchObject([
         LLMEvent.toolResult({
           id: "call_projected",
           name: "projected",
@@ -180,6 +180,7 @@ describe("LLMClient tools", () => {
           output: { structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] },
         }),
       ])
+      expect(dispatched.events[0]?.itemId).toStartWith("fco_")
     }),
   )
 
@@ -197,7 +198,7 @@ describe("LLMClient tools", () => {
         LLMEvent.toolCall({ id: "call_1", name: "tool", input: {}, providerMetadata }),
       )
 
-      expect(dispatched.events).toEqual([
+      expect(dispatched.events).toMatchObject([
         LLMEvent.toolResult({
           id: "call_1",
           name: "tool",
@@ -206,12 +207,13 @@ describe("LLMClient tools", () => {
           providerMetadata,
         }),
       ])
+      expect(dispatched.events[0]?.itemId).toStartWith("fco_")
 
       const failed = yield* ToolRuntime.dispatch(
         {},
-        LLMEvent.toolCall({ id: "call_2", name: "missing", input: {}, providerMetadata }),
+        LLMEvent.toolCall({ id: "call_2", itemId: "fc_failed", name: "missing", input: {}, providerMetadata }),
       )
-      expect(failed.events).toEqual([
+      expect(failed.events).toMatchObject([
         LLMEvent.toolError({
           id: "call_2",
           name: "missing",
@@ -225,6 +227,28 @@ describe("LLMClient tools", () => {
           providerMetadata,
         }),
       ])
+      const errorItemID = failed.events.find(LLMEvent.is.toolError)?.itemId
+      const resultItemID = failed.events.find(LLMEvent.is.toolResult)?.itemId
+      expect(errorItemID).toBe("fco_failed")
+      expect(resultItemID).toBe("fco_failed")
+      expect(errorItemID).not.toBe("fc_failed")
+    }),
+  )
+
+  it.effect("derives a stable function output item id from the function call item id", () =>
+    Effect.gen(function* () {
+      const tool = Tool.make({
+        description: "Return text.",
+        parameters: Schema.Struct({}),
+        success: Schema.String,
+        execute: () => Effect.succeed("hello"),
+      })
+      const dispatched = yield* ToolRuntime.dispatch(
+        { tool },
+        LLMEvent.toolCall({ id: "call_1", itemId: "fc_existing", name: "tool", input: {} }),
+      )
+
+      expect(dispatched.events.find(LLMEvent.is.toolResult)?.itemId).toBe("fco_existing")
     }),
   )
 
@@ -437,7 +461,7 @@ describe("LLMClient tools", () => {
       )
 
       expect(dispatched.result).toEqual(callerOwned)
-      expect(dispatched.events).toEqual([
+      expect(dispatched.events).toMatchObject([
         LLMEvent.toolResult({
           id: "call_1",
           name: "eventful",
@@ -445,6 +469,7 @@ describe("LLMClient tools", () => {
           output: { structured: { ok: true }, content: [] },
         }),
       ])
+      expect(dispatched.events[0]?.itemId).toStartWith("fco_")
     }),
   )
 

+ 28 - 19
packages/core/src/session/runner/publish-llm-event.ts

@@ -1,4 +1,4 @@
-import { type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai"
+import { ResponseItemID, type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai"
 import { Effect } from "effect"
 import { Bus } from "../../bus"
 import { Model } from "../../model"
@@ -116,7 +116,11 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
   })
   const currentAssistantMessageID = () =>
     stepStarted ? Effect.succeed(assistantMessageID) : Effect.die(new Error("Tool event before assistant step start"))
-  const providerState = (metadata: ProviderMetadata | undefined) => metadata?.[input.providerMetadataKey]
+  const providerState = (metadata: ProviderMetadata | undefined, itemId?: string) => {
+    const state = metadata?.[input.providerMetadataKey]
+    if (itemId === undefined) return state
+    return { ...(typeof state === "object" && state !== null && !Array.isArray(state) ? state : {}), itemId }
+  }
   const fragments = (
     name: string,
     ended: (id: string, value: string, ordinal: number, state?: Record<string, unknown>) => Effect.Effect<void>,
@@ -265,6 +269,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
       },
       ...failureSnapshot(tool),
       executed: false,
+      resultState: { itemId: ResponseItemID.create("fco") },
     })
   })
 
@@ -283,6 +288,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
       error,
       ...failureSnapshot(tool),
       executed: tool.providerExecuted,
+      resultState: tool.providerExecuted ? undefined : { itemId: ResponseItemID.create("fco") },
     })
     return true
   })
@@ -340,7 +346,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
         return
       case "text-start":
         outputStarted = true
-        const startedTextOrdinal = yield* text.start(event.id, providerState(event.providerMetadata))
+        const startedTextOrdinal = yield* text.start(event.id, providerState(event.providerMetadata, event.itemId))
         yield* bus.publish(SessionEvent.Text.Started, {
           sessionID: input.sessionID,
           assistantMessageID: yield* startAssistant(),
@@ -348,7 +354,11 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
         })
         return
       case "text-delta":
-        const deltaTextOrdinal = yield* text.append(event.id, event.text, providerState(event.providerMetadata))
+        const deltaTextOrdinal = yield* text.append(
+          event.id,
+          event.text,
+          providerState(event.providerMetadata, event.itemId),
+        )
         yield* bus.publish(SessionEvent.Text.Delta, {
           sessionID: input.sessionID,
           assistantMessageID: yield* currentAssistantMessageID(),
@@ -357,23 +367,26 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
         })
         return
       case "text-end":
-        yield* text.end(event.id, providerState(event.providerMetadata))
+        yield* text.end(event.id, providerState(event.providerMetadata, event.itemId))
         return
       case "reasoning-start":
         outputStarted = true
-        const startedReasoningOrdinal = yield* reasoning.start(event.id, providerState(event.providerMetadata))
+        const startedReasoningOrdinal = yield* reasoning.start(
+          event.id,
+          providerState(event.providerMetadata, event.itemId),
+        )
         yield* bus.publish(SessionEvent.Reasoning.Started, {
           sessionID: input.sessionID,
           assistantMessageID: yield* startAssistant(),
           ordinal: startedReasoningOrdinal,
-          state: providerState(event.providerMetadata),
+          state: providerState(event.providerMetadata, event.itemId),
         })
         return
       case "reasoning-delta":
         const deltaReasoningOrdinal = yield* reasoning.append(
           event.id,
           event.text,
-          providerState(event.providerMetadata),
+          providerState(event.providerMetadata, event.itemId),
         )
         yield* bus.publish(SessionEvent.Reasoning.Delta, {
           sessionID: input.sessionID,
@@ -383,7 +396,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
         })
         return
       case "reasoning-end":
-        yield* reasoning.end(event.id, providerState(event.providerMetadata))
+        yield* reasoning.end(event.id, providerState(event.providerMetadata, event.itemId))
         return
       case "tool-input-start":
         outputStarted = true
@@ -427,7 +440,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
           id: event.id,
           input: asRecord(event.input),
           executed: tool.providerExecuted,
-          state: providerState(event.providerMetadata),
+          state: providerState(event.providerMetadata, event.itemId),
         })
         return
       }
@@ -445,7 +458,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
         }
         tool.settled = true
         const executed = event.providerExecuted === true || tool.providerExecuted
-        const resultState = providerState(event.providerMetadata)
+        const resultState = providerState(event.providerMetadata, event.itemId)
         if (event.result.type === "error") {
           yield* bus.publish(SessionEvent.Tool.Failed, {
             sessionID: input.sessionID,
@@ -485,7 +498,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
               : { type: "tool.execution", message: event.message },
           ...failureSnapshot(tool),
           executed: tool.providerExecuted,
-          resultState: providerState(event.providerMetadata),
+          resultState: providerState(event.providerMetadata, event.itemId) ?? { itemId: ResponseItemID.create("fco") },
         })
         return
       }
@@ -510,8 +523,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
 
   const progress = Effect.fnUntraced(function* (id: string, update: Tool.Metadata) {
     const tool = tools.get(id)
-    if (!tool?.called || tool.settled)
-      return yield* Effect.die(new Error(`Tool progress outside running call: ${id}`))
+    if (!tool?.called || tool.settled) return yield* Effect.die(new Error(`Tool progress outside running call: ${id}`))
     tool.progress = update
     yield* bus.publish(SessionEvent.Tool.Progress, {
       sessionID: input.sessionID,
@@ -522,11 +534,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
   })
 
   /** Publishes one canonical terminal event for a locally executed tool call. */
-  const toolExecution = Effect.fnUntraced(function* (
-    id: string,
-    name: string,
-    result: Tool.Result,
-  ) {
+  const toolExecution = Effect.fnUntraced(function* (id: string, name: string, result: Tool.Result) {
     const tool = tools.get(id)
     if (!tool?.called) return yield* Effect.die(new Error(`Tool execution before call: ${id}`))
     if (tool.name !== name)
@@ -547,6 +555,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
       content: [content[0], ...content.slice(1)],
       ...(result.metadata === undefined ? {} : { metadata: result.metadata }),
       executed: tool.providerExecuted,
+      resultState: tool.providerExecuted ? undefined : { itemId: ResponseItemID.create("fco") },
     })
   })
 

+ 46 - 8
packages/core/src/session/runner/to-llm-message.ts

@@ -1,4 +1,10 @@
-import { Message, ToolCallPart, ToolResultPart, type ContentPart, type ProviderMetadata } from "@opencode-ai/ai"
+import {
+  Message,
+  ToolCallPart,
+  ToolResultPart,
+  type ContentPart,
+  type ProviderMetadata,
+} from "@opencode-ai/ai"
 import { Option, Schema } from "effect"
 import type { Model } from "../../model"
 import { SessionMessage } from "../message"
@@ -66,27 +72,46 @@ const providerMetadata = (
   state: Record<string, unknown> | undefined,
 ): ProviderMetadata | undefined => (state === undefined ? undefined : { [provider]: state })
 
+const responseItemID = (state: Record<string, unknown> | undefined) =>
+  typeof state?.itemId === "string" ? state.itemId : undefined
+
+const portableProviderState = (state: Record<string, unknown> | undefined) => {
+  if (state === undefined || !("itemId" in state)) return state
+  const { itemId: _itemId, ...portable } = state
+  return portable
+}
+
 const toolInput = (tool: SessionMessage.AssistantTool) =>
   tool.state.status === "streaming"
     ? Option.getOrElse(decodeToolInput(tool.state.input), () => tool.state.input)
     : tool.state.input
 
-const toolCall = (tool: SessionMessage.AssistantTool, providerMetadata: ProviderMetadata | undefined): ContentPart =>
+const toolCall = (
+  tool: SessionMessage.AssistantTool,
+  itemId: string | undefined,
+  providerMetadata: ProviderMetadata | undefined,
+): ContentPart =>
   ToolCallPart.make({
     id: tool.id,
+    ...(itemId === undefined ? {} : { itemId }),
     name: tool.name,
     input: toolInput(tool),
     providerExecuted: tool.executed,
     providerMetadata,
   })
 
-const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: ProviderMetadata | undefined) => {
+const toolResult = (
+  tool: SessionMessage.AssistantTool,
+  itemId: string | undefined,
+  providerMetadata: ProviderMetadata | undefined,
+) => {
   if (tool.state.status === "completed") {
     // TODO: Materialize remote and managed URIs before provider-history lowering.
     const content = tool.state.content
     const single = content.length === 1 ? content[0] : undefined
     return ToolResultPart.make({
       id: tool.id,
+      ...(itemId === undefined ? {} : { itemId }),
       name: tool.name,
       result:
         single?.type === "text"
@@ -99,6 +124,7 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
   if (tool.state.status === "error") {
     return ToolResultPart.make({
       id: tool.id,
+      ...(itemId === undefined ? {} : { itemId }),
       name: tool.name,
       result: { error: tool.state.error, content: tool.state.content ?? [] },
       resultType: "error",
@@ -118,7 +144,13 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
         {
           type: "text",
           text: item.text,
-          providerMetadata: sameProvider ? providerMetadata(providerMetadataKey, item.state) : undefined,
+          itemId: reuseProviderMetadata ? responseItemID(item.state) : undefined,
+          providerMetadata: sameProvider
+            ? providerMetadata(
+                providerMetadataKey,
+                reuseProviderMetadata ? item.state : portableProviderState(item.state),
+              )
+            : undefined,
         },
       ]
     if (item.type === "reasoning")
@@ -127,6 +159,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
             {
               type: "reasoning",
               text: item.text,
+              itemId: responseItemID(item.state),
               providerMetadata: providerMetadata(providerMetadataKey, item.state),
             },
           ]
@@ -138,6 +171,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
       (sameModel && item.executed === true && (item.state.status === "completed" || item.state.status === "error"))
     const call = toolCall(
       item,
+      reuseToolProviderMetadata ? responseItemID(item.providerState) : undefined,
       reuseToolProviderMetadata ? providerMetadata(providerMetadataKey, item.providerState) : undefined,
     )
     if (item.executed !== true) return [call]
@@ -145,6 +179,11 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
     // replay must survive a model switch within the same provider.
     const result = toolResult(
       item,
+      reuseToolProviderMetadata
+        ? responseItemID(item.providerResultState ?? (item.executed === true ? item.providerState : undefined))
+        : sameProvider && item.executed === true
+          ? responseItemID(item.providerResultState)
+          : undefined,
       reuseToolProviderMetadata
         ? providerMetadata(providerMetadataKey, item.providerResultState ?? item.providerState)
         : sameProvider && item.executed === true && item.providerResultState !== undefined
@@ -163,9 +202,8 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
     .map((item) =>
       toolResult(
         item,
-        reuseProviderMetadata
-          ? providerMetadata(providerMetadataKey, item.providerResultState ?? item.providerState)
-          : undefined,
+        responseItemID(item.providerResultState) ?? `fco_${item.id}`,
+        reuseProviderMetadata ? providerMetadata(providerMetadataKey, item.providerResultState) : undefined,
       ),
     )
     .filter((message) => message !== undefined)
@@ -204,7 +242,7 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
     case "skill":
       return [Message.make({ id: message.id, role: "user", content: message.text, metadata: message.metadata })]
     case "system":
-      return [Message.system(message.text)]
+      return [Message.make({ id: message.id, role: "system", content: message.text })]
     case "shell":
       return [
         Message.make({

+ 17 - 1
packages/core/test/session-runner-message.test.ts

@@ -110,7 +110,13 @@ describe("toLLMMessages", () => {
     )
 
     expect(messages.map((message) => message.role)).toEqual(["system", "user", "user", "user", "user"])
-    expect(messages[0]).toEqual(Message.system("Updated context\n\nOther context"))
+    expect(messages[0]).toEqual(
+      Message.make({
+        id: "msg_system",
+        role: "system",
+        content: [{ type: "text", text: "Updated context\n\nOther context" }],
+      }),
+    )
     expect(messages[1]).toEqual(
       Message.make({
         id: id("user"),
@@ -476,6 +482,7 @@ Recent work
       {
         type: "tool-result",
         id: "completed",
+        itemId: "fco_completed",
         name: "read",
         result: {
           type: "content",
@@ -513,6 +520,7 @@ Recent work
       {
         type: "reasoning",
         text: "Think",
+        itemId: "rs_1",
         providerMetadata: { provider: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
       },
     ])
@@ -602,6 +610,7 @@ Recent work
       {
         type: "tool-call",
         id: "hosted-completed",
+        itemId: "call_completed",
         name: "web_search",
         input: { query: "Effect" },
         providerExecuted: true,
@@ -610,6 +619,7 @@ Recent work
       {
         type: "tool-result",
         id: "hosted-completed",
+        itemId: "result_completed",
         name: "web_search",
         result: { type: "text", value: '{"found":true}' },
         providerExecuted: true,
@@ -620,6 +630,7 @@ Recent work
       {
         type: "tool-call",
         id: "hosted-failed",
+        itemId: "call_failed",
         name: "web_search",
         input: { query: "Effect" },
         providerExecuted: true,
@@ -628,6 +639,7 @@ Recent work
       {
         type: "tool-result",
         id: "hosted-failed",
+        itemId: "result_failed",
         name: "web_search",
         result: {
           type: "error",
@@ -698,6 +710,7 @@ Recent work
       {
         type: "tool-call",
         id: "hosted-old-model",
+        itemId: undefined,
         name: "web_search",
         input: { query: "Effect" },
         providerExecuted: true,
@@ -706,6 +719,7 @@ Recent work
       {
         type: "tool-result",
         id: "hosted-old-model",
+        itemId: "hosted-old-model",
         name: "web_search",
         result: { type: "text", value: '{"status":"completed"}' },
         providerExecuted: true,
@@ -718,6 +732,7 @@ Recent work
       {
         type: "tool-call",
         id: "local-old-model",
+        itemId: undefined,
         name: "read",
         input: { path: "README.md" },
         providerExecuted: false,
@@ -728,6 +743,7 @@ Recent work
       {
         type: "tool-result",
         id: "local-old-model",
+        itemId: "fco_local-old-model",
         name: "read",
         result: { type: "text", value: "Hello" },
         providerExecuted: false,

+ 10 - 8
packages/core/test/session-runner-tool-events.test.ts

@@ -24,9 +24,7 @@ const capture = (providerMetadataKey = "anthropic", options?: { readonly interru
       const publish = Effect.sync(() => {
         const event = { id: Event.ID.create(), type: definition.type, data } as Event.Payload<typeof definition>
         published.push({
-          type: definition.durable
-            ? Bus.versionedType(definition.type, definition.durable.version)
-            : definition.type,
+          type: definition.durable ? Bus.versionedType(definition.type, definition.durable.version) : definition.type,
           data,
         })
         return event
@@ -66,9 +64,10 @@ const hostedResult = LLMEvent.toolResult({
 
 test("local tool success serializes media base64 once through canonical content", async () => {
   const { published, publisher } = capture()
-  await Effect.runPromise(publisher.publish(call))
+  const localCall = LLMEvent.toolCall({ ...call, itemId: "fc_call-image" })
+  await Effect.runPromise(publisher.publish(localCall))
   await Effect.runPromise(
-    publisher.toolExecution(call.id, call.name, {
+    publisher.toolExecution(localCall.id, localCall.name, {
       output: { type: "media", mime: "image/png" },
       content: [
         { type: "text", text: "Image read successfully" },
@@ -84,6 +83,11 @@ test("local tool success serializes media base64 once through canonical content"
   expect(success?.data).not.toHaveProperty("result")
   expect(success?.data).not.toHaveProperty("output")
 
+  const called = published.find((event) => event.type === "session.tool.called.1")?.data
+  expect(called).toMatchObject({ state: { itemId: "fc_call-image" } })
+  expect(success?.data).toMatchObject({ resultState: { itemId: expect.stringMatching(/^fco_/) } })
+  expect(JSON.stringify(success?.data)).not.toContain('"itemId":"fc_call-image"')
+
   expect(success?.data).toMatchObject({
     content: [
       { type: "text", text: "Image read successfully" },
@@ -226,9 +230,7 @@ test("provider-executed tool metadata is flattened using the route key", async (
 test("binary failure emits no success event", async () => {
   const { published, publisher } = capture()
   await Effect.runPromise(publisher.publish(call))
-  await Effect.runPromise(
-    publisher.failTool(call.id, { type: "tool.execution", message: "Cannot read binary file" }),
-  )
+  await Effect.runPromise(publisher.failTool(call.id, { type: "tool.execution", message: "Cannot read binary file" }))
   expect(published.some((event) => event.type === "session.tool.success.2")).toBe(false)
   expect(published.some((event) => event.type === "session.tool.failed.2")).toBe(true)
 })

+ 42 - 9
packages/core/test/session-runner.test.ts

@@ -558,6 +558,15 @@ const messageTexts = (request: LLMRequest, role: "user" | "system") =>
 const userTexts = (request: LLMRequest) => messageTexts(request, "user")
 const systemTexts = (request: LLMRequest) => messageTexts(request, "system")
 const messageRoles = (request: LLMRequest | undefined) => request?.messages.map((message) => message.role)
+const withoutItemIDs = (messages: LLMRequest["messages"]) =>
+  messages.map((message) => ({
+    role: message.role,
+    content: message.content.map((part) => {
+      if (!("itemId" in part)) return part
+      const { itemId: _itemId, ...content } = part
+      return content
+    }),
+  }))
 
 const recordedEventTypes = (id: Session.ID) =>
   Effect.gen(function* () {
@@ -856,8 +865,8 @@ describe("SessionRunnerLLM", () => {
       yield* Fiber.join(renamed)
 
       expect(requests).toHaveLength(5)
-      expect(requests[2]?.messages).toContainEqual(Message.user("First prompt"))
-      expect(requests[4]?.messages).toContainEqual(Message.user("First prompt"))
+      expect(withoutItemIDs(requests[2]!.messages)).toContainEqual(withoutItemIDs([Message.user("First prompt")])[0])
+      expect(withoutItemIDs(requests[4]!.messages)).toContainEqual(withoutItemIDs([Message.user("First prompt")])[0])
       expect((yield* session.get(sessionID)).title).toBe("Generated title")
     }),
   )
@@ -882,7 +891,7 @@ describe("SessionRunnerLLM", () => {
       // A hook-removed call fails independently and continues while step allowance remains.
       expect(requests).toHaveLength(2)
       expect(requests[0]?.system.map((part) => part.text)).toEqual(["Hooked system"])
-      expect(requests[0]?.messages).toEqual([Message.user("Hooked message")])
+      expect(withoutItemIDs(requests[0]!.messages)).toEqual(withoutItemIDs([Message.user("Hooked message")]))
       expect(requests[0]?.tools.map((tool) => tool.name)).not.toContain("echo")
       expect(requests[0]?.tools.map((tool) => tool.name)).not.toContain("unregistered")
       expect(executions).toEqual([])
@@ -1307,12 +1316,14 @@ describe("SessionRunnerLLM", () => {
       systemBaseline = "Changed context"
       yield* runPrompt(session, "Second")
 
-      expect(
-        PromptCacheDiagnostics.compare(
-          PromptCacheDiagnostics.snapshot(requests[0]),
-          PromptCacheDiagnostics.snapshot(requests[1]),
-        ),
-      ).toEqual({ status: "append-only", previousMessages: 1, currentMessages: 3 })
+      const firstSnapshot = PromptCacheDiagnostics.snapshot(requests[0]!)
+      const secondSnapshot = PromptCacheDiagnostics.snapshot(requests[1]!)
+      expect(PromptCacheDiagnostics.compare(firstSnapshot, secondSnapshot)).toEqual({
+        status: "append-only",
+        previousMessages: 1,
+        currentMessages: 3,
+      })
+      expect(secondSnapshot.messages[0]).toEqual(firstSnapshot.messages[0])
       expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
         [defaultSystem, "Initial context"],
         [defaultSystem, "Initial context"],
@@ -2523,18 +2534,35 @@ describe("SessionRunnerLLM", () => {
       yield* TestLLM.push([])
       yield* session.resume(sessionID)
 
+      const assistant = requests[1]?.messages[1]
       expect(requests[1]?.messages[1]?.content).toEqual([
         {
           type: "reasoning",
           text: "Signed thought",
+          itemId: `rs_${assistant?.id}_0`,
           providerMetadata: { openai: { signature: "sig_1" } },
         },
         {
           type: "reasoning",
           text: "Encrypted thought",
+          itemId: "rs_1",
           providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
         },
       ])
+
+      yield* admit(session, "Continue again")
+      yield* TestLLM.push([])
+      yield* session.resume(sessionID)
+
+      expect(requests[2]?.messages[1]?.content.map((part) => ("itemId" in part ? part.itemId : undefined))).toEqual(
+        requests[1]?.messages[1]?.content.map((part) => ("itemId" in part ? part.itemId : undefined)),
+      )
+      expect(
+        PromptCacheDiagnostics.compare(
+          PromptCacheDiagnostics.snapshot(requests[1]!),
+          PromptCacheDiagnostics.snapshot(requests[2]!),
+        ),
+      ).toEqual({ status: "append-only", previousMessages: 3, currentMessages: 4 })
     }),
   )
 
@@ -2568,10 +2596,12 @@ describe("SessionRunnerLLM", () => {
       yield* TestLLM.push([])
       yield* session.resume(sessionID)
 
+      const assistant = requests[1]?.messages[1]
       expect(requests[1]?.messages[1]?.content).toEqual([
         {
           type: "text",
           text: "Checking.",
+          itemId: `msg_${assistant?.id}_0`,
           providerMetadata: { openai: { phase: "commentary" } },
         },
       ])
@@ -2609,10 +2639,12 @@ describe("SessionRunnerLLM", () => {
       yield* session.resume(sessionID)
 
       expect(messageRoles(requests[1])).toEqual(["user", "assistant", "user"])
+      const assistant = requests[1]?.messages[1]
       expect(requests[1]?.messages[1]?.content).toMatchObject([
         {
           type: "tool-call",
           id: "hosted-search",
+          itemId: "hosted-search",
           name: "web_search",
           input: { query: "Effect" },
           providerExecuted: true,
@@ -2621,6 +2653,7 @@ describe("SessionRunnerLLM", () => {
         {
           type: "tool-result",
           id: "hosted-search",
+          itemId: `fco_${assistant?.id}_1`,
           name: "web_search",
           // The generic replay result derives from canonical stored content.
           result: { type: "text", value: '[{"title":"Effect"}]' },

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff