Procházet zdrojové kódy

fix(llm): preserve response message phases (#38452)

Aiden Cline před 3 týdny
rodič
revize
4b19ea2a71

+ 265 - 5
packages/llm/src/protocols/openai-responses.ts

@@ -46,8 +46,12 @@ type OpenAIResponsesInputContent = Schema.Schema.Type<typeof OpenAIResponsesInpu
 const OpenAIResponsesOutputText = Schema.Struct({
   type: Schema.tag("output_text"),
   text: Schema.String,
+  annotations: Schema.Array(Schema.Unknown),
 })
 
+const OpenAIResponsesMessagePhase = Schema.Literals(["commentary", "final_answer"])
+type OpenAIResponsesMessagePhase = Schema.Schema.Type<typeof OpenAIResponsesMessagePhase>
+
 const OpenAIResponsesReasoningSummaryText = Schema.Struct({
   type: Schema.tag("summary_text"),
   text: Schema.String,
@@ -78,7 +82,19 @@ const OpenAIResponsesFunctionCallOutput = Schema.Union([
 const OpenAIResponsesInputItem = Schema.Union([
   Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
   Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenAIResponsesInputContent) }),
-  Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenAIResponsesOutputText) }),
+  Schema.Struct({
+    role: Schema.tag("assistant"),
+    content: Schema.String,
+    phase: optionalNull(OpenAIResponsesMessagePhase),
+  }),
+  Schema.Struct({
+    type: Schema.tag("message"),
+    id: Schema.String,
+    status: Schema.Literals(["in_progress", "completed", "incomplete"]),
+    role: Schema.tag("assistant"),
+    content: Schema.Array(OpenAIResponsesOutputText),
+    phase: optionalNull(OpenAIResponsesMessagePhase),
+  }),
   OpenAIResponsesReasoningItem,
   OpenAIResponsesItemReference,
   Schema.Struct({
@@ -194,7 +210,9 @@ const OpenAIResponsesStreamItem = Schema.Struct({
   server_label: Schema.optional(Schema.String),
   output: Schema.optional(Schema.Unknown),
   error: Schema.optional(Schema.Unknown),
+  content: Schema.optional(Schema.Array(Schema.Unknown)),
   encrypted_content: optionalNull(Schema.String),
+  phase: optionalNull(OpenAIResponsesMessagePhase),
 })
 type OpenAIResponsesStreamItem = Schema.Schema.Type<typeof OpenAIResponsesStreamItem>
 
@@ -212,7 +230,9 @@ const OpenAIResponsesErrorPayload = Schema.Struct({
 const OpenAIResponsesEvent = Schema.Struct({
   type: Schema.String,
   delta: Schema.optional(Schema.String),
+  text: Schema.optional(Schema.String),
   item_id: Schema.optional(Schema.String),
+  content_index: Schema.optional(Schema.Number),
   summary_index: Schema.optional(Schema.Number),
   item: Schema.optional(OpenAIResponsesStreamItem),
   response: Schema.optional(
@@ -237,10 +257,18 @@ interface ParserState {
   readonly tools: ToolStream.State<string>
   readonly hasFunctionCall: boolean
   readonly lifecycle: Lifecycle.State
+  readonly messageItems: Readonly<Record<string, MessageStreamItem>>
+  readonly messageContentIDs: ReadonlySet<string>
+  readonly nextMessageContentID: number
   readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
   readonly store: boolean | undefined
 }
 
+interface MessageStreamItem {
+  readonly providerMetadata?: ProviderMetadata
+  readonly content: Readonly<Record<number, { readonly id: string; readonly text: string }>>
+}
+
 type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
 
 interface ReasoningStreamItem {
@@ -298,6 +326,26 @@ const lowerReasoning = (part: ReasoningPart): OpenAIResponsesReasoningInput | un
   }
 }
 
+const messagePhase = (part: TextPart): OpenAIResponsesMessagePhase | null | undefined => {
+  const phase = part.providerMetadata?.openai?.phase
+  return phase === "commentary" || phase === "final_answer" || phase === null ? phase : undefined
+}
+
+const messageItemID = (part: TextPart) => {
+  const itemID = part.providerMetadata?.openai?.itemId
+  return typeof itemID === "string" && itemID.length > 0 ? itemID : undefined
+}
+
+const messageStatus = (part: TextPart) => {
+  const status = part.providerMetadata?.openai?.status
+  return status === "in_progress" || status === "completed" || status === "incomplete" ? status : undefined
+}
+
+const messageAnnotations = (part: TextPart) => {
+  const annotations = part.providerMetadata?.openai?.annotations
+  return Array.isArray(annotations) ? annotations : []
+}
+
 const hostedToolItemID = (part: ToolResultPart) => {
   const openai = part.providerMetadata?.openai
   return ProviderShared.isRecord(openai) && typeof openai.itemId === "string" && openai.itemId.length > 0
@@ -368,17 +416,49 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
     }
 
     if (message.role === "assistant") {
+      const inputStart = input.length
       const content: TextPart[] = []
+      let phase: OpenAIResponsesMessagePhase | null | undefined
+      let itemID: string | undefined
+      let status: "in_progress" | "completed" | "incomplete" | undefined
       const reasoningItems: Record<string, OpenAIResponsesReasoningReplay> = {}
       const reasoningReferences = new Set<string>()
       const hostedToolReferences = new Set<string>()
       const flushText = () => {
         if (content.length === 0) return
-        input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) })
+        input.push(
+          itemID
+            ? {
+                type: "message",
+                id: itemID,
+                status: status ?? "completed",
+                role: "assistant",
+                content: content.map((part) => ({
+                  type: "output_text",
+                  text: part.text,
+                  annotations: messageAnnotations(part),
+                })),
+                ...(phase !== undefined ? { phase } : {}),
+              }
+            : {
+                role: "assistant",
+                content: ProviderShared.joinText(content),
+                ...(phase !== undefined ? { phase } : {}),
+              },
+        )
         content.splice(0, content.length)
+        phase = undefined
+        itemID = undefined
+        status = undefined
       }
       for (const part of message.content) {
         if (part.type === "text") {
+          const nextPhase = messagePhase(part)
+          const nextItemID = messageItemID(part)
+          if (content.length > 0 && (phase !== nextPhase || itemID !== nextItemID)) flushText()
+          phase = nextPhase
+          itemID = nextItemID
+          status = messageStatus(part) ?? status
           content.push(part)
           continue
         }
@@ -429,6 +509,20 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
         ])
       }
       flushText()
+      if (store === false && Object.values(reasoningItems).some((item) => typeof item.encrypted_content !== "string"))
+        input.splice(
+          inputStart,
+          input.length - inputStart,
+          ...input.slice(inputStart).map((item) =>
+            "type" in item && item.type === "message"
+              ? {
+                  role: "assistant" as const,
+                  content: ProviderShared.joinText(item.content),
+                  ...(item.phase !== undefined ? { phase: item.phase } : {}),
+                }
+              : item,
+          ),
+        )
       continue
     }
 
@@ -612,15 +706,134 @@ const NO_EVENTS: StepResult["1"] = []
 // the protocol's `terminal` predicate stay in sync.
 const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"])
 
-const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
-  if (!event.delta) return [state, NO_EVENTS]
+const messageMetadata = (item: OpenAIResponsesStreamItem, id: string, previous?: ProviderMetadata) => {
+  const openai = previous?.openai
+  const phase = item.phase !== undefined ? item.phase : openai?.phase
+  const status =
+    item.status === "in_progress" || item.status === "completed" || item.status === "incomplete"
+      ? item.status
+      : openai?.status
+  return openaiMetadata({
+    itemId: id,
+    ...(phase === "commentary" || phase === "final_answer" || phase === null ? { phase } : {}),
+    ...(status === "in_progress" || status === "completed" || status === "incomplete" ? { status } : {}),
+  })
+}
+
+const messageContentMetadata = (
+  providerMetadata: ProviderMetadata,
+  item: OpenAIResponsesStreamItem,
+  index: number,
+): ProviderMetadata => {
+  const content = item.content?.[index]
+  if (!ProviderShared.isRecord(content) || content.type !== "output_text" || !Array.isArray(content.annotations))
+    return providerMetadata
+  return openaiMetadata({ ...providerMetadata.openai, annotations: content.annotations })
+}
+
+const ensureMessageContent = (state: ParserState, event: OpenAIResponsesEvent) => {
+  const itemID = event.item_id ?? "text-0"
+  const index = event.content_index ?? 0
+  const item = state.messageItems[itemID] ?? { content: {} }
+  const existing = item.content[index]
+  if (existing) return { state, itemID, index, item, content: existing }
+  const findID = (next: number): readonly [string, number] => {
+    const id = `openai-text-${next}`
+    return state.messageContentIDs.has(id) ? findID(next + 1) : [id, next + 1]
+  }
+  const [id, nextMessageContentID] =
+    index === 0 && !state.messageContentIDs.has(itemID)
+      ? ([itemID, state.nextMessageContentID] as const)
+      : findID(state.nextMessageContentID)
+  const content = { id, text: "" }
+  const nextItem = { ...item, content: { ...item.content, [index]: content } }
+  return {
+    state: {
+      ...state,
+      messageItems: { ...state.messageItems, [itemID]: nextItem },
+      messageContentIDs: new Set([...state.messageContentIDs, id]),
+      nextMessageContentID,
+    },
+    itemID,
+    index,
+    item: nextItem,
+    content,
+  }
+}
+
+const updateMessageContent = (
+  state: ParserState,
+  itemID: string,
+  index: number,
+  content: { readonly id: string; readonly text: string },
+): ParserState => ({
+  ...state,
+  messageItems: {
+    ...state.messageItems,
+    [itemID]: {
+      ...state.messageItems[itemID],
+      content: { ...state.messageItems[itemID]?.content, [index]: content },
+    },
+  },
+})
+
+const closeOtherMessageContent = (state: ParserState, events: LLMEvent[], item: MessageStreamItem, index: number) =>
+  Object.entries(item.content).reduce(
+    (lifecycle, entry) =>
+      Number(entry[0]) === index ? lifecycle : Lifecycle.textEnd(lifecycle, events, entry[1].id, item.providerMetadata),
+    state.lifecycle,
+  )
+
+const appendOutputText = (state: ParserState, event: OpenAIResponsesEvent, text: string): StepResult => {
+  const ensured = ensureMessageContent(state, event)
   const events: LLMEvent[] = []
+  const lifecycle = Lifecycle.textStart(
+    closeOtherMessageContent(ensured.state, events, ensured.item, ensured.index),
+    events,
+    ensured.content.id,
+    ensured.item.providerMetadata,
+  )
   return [
-    { ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, event.item_id ?? "text-0", event.delta) },
+    {
+      ...updateMessageContent(ensured.state, ensured.itemID, ensured.index, {
+        ...ensured.content,
+        text: ensured.content.text + text,
+      }),
+      lifecycle: Lifecycle.textDelta(lifecycle, events, ensured.content.id, text),
+    },
     events,
   ]
 }
 
+const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
+  if (!event.delta) return [state, NO_EVENTS]
+  return appendOutputText(state, event, event.delta)
+}
+
+const onOutputTextDone = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
+  if (event.text === undefined) return [state, NO_EVENTS]
+  const ensured = ensureMessageContent(state, event)
+  if (event.text === ensured.content.text) {
+    if (ensured.state.lifecycle.text.has(ensured.content.id)) return [ensured.state, NO_EVENTS]
+    const events: LLMEvent[] = []
+    return [
+      {
+        ...ensured.state,
+        lifecycle: Lifecycle.textStart(
+          closeOtherMessageContent(ensured.state, events, ensured.item, ensured.index),
+          events,
+          ensured.content.id,
+          ensured.item.providerMetadata,
+        ),
+      },
+      events,
+    ]
+  }
+  if (event.text.startsWith(ensured.content.text))
+    return appendOutputText(ensured.state, event, event.text.slice(ensured.content.text.length))
+  return [ensured.state, NO_EVENTS]
+}
+
 const onReasoningDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
   if (!event.delta) return [state, NO_EVENTS]
   const events: LLMEvent[] = []
@@ -655,6 +868,23 @@ const reasoningMetadata = (item: OpenAIResponsesStreamItem & { id: string }) =>
 // best-effort, not guaranteed.
 const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
   const item = event.item
+  if (item?.type === "message" && item.id) {
+    const existing = state.messageItems[item.id]
+    return [
+      {
+        ...state,
+        messageItems: {
+          ...state.messageItems,
+          [item.id]: {
+            ...existing,
+            providerMetadata: messageMetadata(item, item.id, existing?.providerMetadata),
+            content: existing?.content ?? {},
+          },
+        },
+      },
+      NO_EVENTS,
+    ]
+  }
   if (item && isReasoningItem(item)) {
     const events: LLMEvent[] = []
     return [
@@ -812,6 +1042,32 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
   const item = event.item
   if (!item) return [state, NO_EVENTS] satisfies StepResult
 
+  if (item.type === "message" && item.id) {
+    const events: LLMEvent[] = []
+    const itemID = item.id
+    const messageItem = state.messageItems[itemID]
+    const { [itemID]: _finished, ...messageItems } = state.messageItems
+    const providerMetadata = messageMetadata(item, itemID, messageItem?.providerMetadata)
+    const lifecycle = Object.entries(messageItem?.content ?? {}).reduce(
+      (lifecycle, entry) =>
+        Lifecycle.textEnd(
+          lifecycle,
+          events,
+          entry[1].id,
+          messageContentMetadata(providerMetadata, item, Number(entry[0])),
+        ),
+      state.lifecycle,
+    )
+    return [
+      {
+        ...state,
+        lifecycle,
+        messageItems,
+      },
+      events,
+    ] satisfies StepResult
+  }
+
   if (item.type === "function_call") {
     if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
     const tools = state.tools[item.id]
@@ -939,6 +1195,7 @@ const step = (state: ParserState, event: OpenAIResponsesEvent) => {
   if (event.type === "response.reasoning_summary_part.done")
     return Effect.succeed(onReasoningSummaryPartDone(state, event))
   if (event.type === "response.output_item.added") return Effect.succeed(onOutputItemAdded(state, event))
+  if (event.type === "response.output_text.done") return Effect.succeed(onOutputTextDone(state, event))
   if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event)
   if (event.type === "response.output_item.done") return onOutputItemDone(state, event)
   if (event.type === "response.completed" || event.type === "response.incomplete")
@@ -968,6 +1225,9 @@ export const protocol = Protocol.make({
       hasFunctionCall: false,
       tools: ToolStream.empty<string>(),
       lifecycle: Lifecycle.initial(),
+      messageItems: {},
+      messageContentIDs: new Set<string>(),
+      nextMessageContentID: 0,
       reasoningItems: {},
       store: OpenAIOptions.store(request),
     }),

+ 10 - 7
packages/llm/src/protocols/utils/lifecycle.ts

@@ -14,16 +14,19 @@ export const stepStart = (state: State, events: LLMEvent[]): State => {
   return { ...state, stepStarted: true }
 }
 
-export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
+export const textStart = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
+  if (state.text.has(id)) return state
   const stepped = stepStart(state, events)
-  if (stepped.text.has(id)) {
-    events.push(LLMEvent.textDelta({ id, text }))
-    return stepped
-  }
-  events.push(LLMEvent.textStart({ id }), LLMEvent.textDelta({ id, text }))
+  events.push(LLMEvent.textStart({ id, ...(providerMetadata ? { 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 }))
+  return started
+}
+
 export const reasoningStart = (
   state: State,
   events: LLMEvent[],
@@ -65,7 +68,7 @@ export const reasoningEnd = (
 export const textEnd = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
   if (!state.text.has(id)) return state
   const stepped = stepStart(state, events)
-  events.push(LLMEvent.textEnd({ id, providerMetadata }))
+  events.push(LLMEvent.textEnd({ id, ...(providerMetadata ? { providerMetadata } : {}) }))
   const text = new Set(stepped.text)
   text.delete(id)
   return { ...stepped, text }

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 29 - 0
packages/llm/test/fixtures/recordings/openai-responses-phase/round-trips-commentary-into-a-final-answer.json


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1 - 1
packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json


+ 89 - 0
packages/llm/test/provider/openai-responses-phase.recorded.test.ts

@@ -0,0 +1,89 @@
+import { describe, expect } from "bun:test"
+import { Effect } from "effect"
+import { LLM, Message } from "../../src"
+import * as OpenAI from "../../src/providers/openai"
+import { OpenAIResponses } from "../../src/protocols/openai-responses"
+import { LLMClient } from "../../src/route"
+import { weatherTool } from "../recorded-scenarios"
+import { recordedTests } from "../recorded-test"
+
+const model = OpenAI.configure({
+  apiKey: process.env.OPENAI_API_KEY ?? "fixture",
+}).responses("gpt-5.6-sol")
+
+const recorded = recordedTests({
+  prefix: "openai-responses-phase",
+  provider: "openai",
+  protocol: "openai-responses",
+  requires: ["OPENAI_API_KEY"],
+})
+
+describe("OpenAI Responses phase recorded", () => {
+  recorded.effect.with("round-trips commentary into a final answer", { tags: ["phase", "tool"] }, () =>
+    Effect.gen(function* () {
+      const user = Message.user("What is the weather in Paris?")
+      const first = yield* LLMClient.generate(
+        LLM.request({
+          model,
+          system:
+            "Before calling get_weather, briefly tell the user you are checking. Then call get_weather exactly once. Do not provide the final answer until its result is available.",
+          messages: [user],
+          tools: [weatherTool],
+          generation: { maxTokens: 100 },
+        }),
+      )
+      const call = first.toolCalls[0]
+      if (!call) throw new Error("OpenAI Responses did not return the expected weather tool call")
+
+      expect(call).toMatchObject({ name: "get_weather", input: { city: "Paris" } })
+      const commentary = first.message.content.find(
+        (part) => part.type === "text" && part.providerMetadata?.openai?.phase === "commentary",
+      )
+      if (!commentary || commentary.type !== "text") throw new Error("OpenAI Responses did not return commentary text")
+      const itemID = commentary.providerMetadata?.openai?.itemId
+      if (typeof itemID !== "string") throw new Error("OpenAI Responses commentary did not include an item ID")
+      expect(commentary).toEqual({
+        type: "text",
+        text: "I’ll check the current weather in Paris.",
+        providerMetadata: {
+          openai: { itemId: itemID, phase: "commentary", status: "completed", annotations: [] },
+        },
+      })
+
+      const continuation = LLM.request({
+        model,
+        system:
+          "Before calling get_weather, briefly tell the user you are checking. Then call get_weather exactly once. After its result, answer exactly: Paris is sunny.",
+        messages: [
+          user,
+          first.message,
+          Message.tool({
+            id: call.id,
+            name: call.name,
+            result: { temperature: 22, condition: "sunny" },
+          }),
+        ],
+        tools: [weatherTool],
+        generation: { maxTokens: 100 },
+      })
+      const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(continuation)
+      expect(prepared.body.input).toContainEqual({
+        type: "message",
+        id: itemID,
+        status: "completed",
+        role: "assistant",
+        content: [{ type: "output_text", text: commentary.text, annotations: [] }],
+        phase: "commentary",
+      })
+
+      const second = yield* LLMClient.generate(continuation)
+
+      expect(second.text.trim()).toBe("Paris is sunny.")
+      expect(
+        second.message.content.some(
+          (part) => part.type === "text" && part.providerMetadata?.openai?.phase === "final_answer",
+        ),
+      ).toBeTrue()
+    }),
+  )
+})

+ 459 - 8
packages/llm/test/provider/openai-responses.test.ts

@@ -153,7 +153,7 @@ describe("OpenAI Responses route", () => {
             { type: "input_text", text: "<system-update>\nTreat &lt;/system-update&gt; literally.\n</system-update>" },
           ],
         },
-        { role: "assistant", content: [{ type: "output_text", text: "After." }] },
+        { role: "assistant", content: "After." },
       ])
     }),
   )
@@ -529,11 +529,11 @@ describe("OpenAI Responses route", () => {
             encrypted_content: "encrypted-continuation-state",
             summary: [{ type: "summary_text", text: "I inspected the previous turn." }],
           },
-          { role: "assistant", content: [{ type: "output_text", text: "It shows a small test image." }] },
+          { role: "assistant", content: "It shows a small test image." },
           { role: "user", content: [{ type: "input_text", text: "Check the weather in Paris before continuing." }] },
           { type: "function_call", call_id: "call_weather_1", name: "get_weather", arguments: '{"city":"Paris"}' },
           { type: "function_call_output", call_id: "call_weather_1", output: '{"temperature":22}' },
-          { role: "assistant", content: [{ type: "output_text", text: "Paris is 22 degrees." }] },
+          { role: "assistant", content: "Paris is 22 degrees." },
           {
             role: "user",
             content: [{ type: "input_text", text: "Continue from this conversation in one short sentence." }],
@@ -754,6 +754,395 @@ describe("OpenAI Responses route", () => {
     }),
   )
 
+  it.effect("preserves streamed assistant message phases", () =>
+    Effect.gen(function* () {
+      const response = yield* LLMClient.generate(request).pipe(
+        Effect.provide(
+          fixedResponse(
+            sseEvents(
+              {
+                type: "response.output_item.added",
+                item: { type: "message", id: "msg_commentary", phase: "commentary" },
+              },
+              { type: "response.output_text.delta", item_id: "msg_commentary", delta: "Checking first." },
+              { type: "response.output_text.done", item_id: "msg_commentary" },
+              {
+                type: "response.output_item.done",
+                item: { type: "message", id: "msg_commentary", phase: "commentary" },
+              },
+              {
+                type: "response.output_item.added",
+                item: { type: "message", id: "msg_final", phase: "final_answer" },
+              },
+              { type: "response.output_text.delta", item_id: "msg_final", delta: "Finished." },
+              { type: "response.output_text.done", item_id: "msg_final" },
+              {
+                type: "response.output_item.done",
+                item: { type: "message", id: "msg_final", phase: "final_answer" },
+              },
+              { type: "response.completed", response: { id: "resp_1" } },
+            ),
+          ),
+        ),
+      )
+
+      expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([
+        {
+          type: "text-start",
+          id: "msg_commentary",
+          providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
+        },
+        { type: "text-delta", id: "msg_commentary", text: "Checking first." },
+        {
+          type: "text-end",
+          id: "msg_commentary",
+          providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
+        },
+        {
+          type: "text-start",
+          id: "msg_final",
+          providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } },
+        },
+        { type: "text-delta", id: "msg_final", text: "Finished." },
+        {
+          type: "text-end",
+          id: "msg_final",
+          providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } },
+        },
+      ])
+      expect(response.message.content).toEqual([
+        {
+          type: "text",
+          text: "Checking first.",
+          providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
+        },
+        {
+          type: "text",
+          text: "Finished.",
+          providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } },
+        },
+      ])
+    }),
+  )
+
+  it.effect("preserves phased message and content boundaries", () =>
+    Effect.gen(function* () {
+      const response = yield* LLMClient.generate(request).pipe(
+        Effect.provide(
+          fixedResponse(
+            sseEvents(
+              {
+                type: "response.output_item.added",
+                item: { type: "message", id: "msg_commentary", phase: "commentary" },
+              },
+              {
+                type: "response.output_text.delta",
+                item_id: "msg_commentary",
+                content_index: 0,
+                delta: "First.",
+              },
+              {
+                type: "response.output_item.added",
+                item: { type: "message", id: "msg_commentary" },
+              },
+              {
+                type: "response.output_text.done",
+                item_id: "msg_commentary",
+                content_index: 0,
+                text: "First.",
+              },
+              {
+                type: "response.output_text.done",
+                item_id: "msg_commentary",
+                content_index: 1,
+                text: "Second.",
+              },
+              {
+                type: "response.output_item.done",
+                item: { type: "message", id: "msg_commentary" },
+              },
+              {
+                type: "response.output_item.added",
+                item: { type: "message", id: "msg_commentary_2", phase: "commentary" },
+              },
+              {
+                type: "response.output_text.delta",
+                item_id: "msg_commentary_2",
+                content_index: 0,
+                delta: "Thi",
+              },
+              {
+                type: "response.output_text.done",
+                item_id: "msg_commentary_2",
+                content_index: 0,
+                text: "Third.",
+              },
+              {
+                type: "response.output_item.done",
+                item: { type: "message", id: "msg_commentary_2", phase: "commentary" },
+              },
+              {
+                type: "response.output_item.added",
+                item: { type: "message", id: "openai-text-0" },
+              },
+              {
+                type: "response.output_text.done",
+                item_id: "openai-text-0",
+                content_index: 0,
+                text: "Final.",
+              },
+              {
+                type: "response.output_item.done",
+                item: {
+                  type: "message",
+                  id: "openai-text-0",
+                  phase: "final_answer",
+                  content: [
+                    {
+                      type: "output_text",
+                      text: "Final.",
+                      annotations: [
+                        {
+                          type: "url_citation",
+                          url: "https://example.com",
+                          title: "Example",
+                          start_index: 0,
+                          end_index: 6,
+                        },
+                      ],
+                    },
+                  ],
+                },
+              },
+              {
+                type: "response.output_item.added",
+                item: { type: "message", id: "msg_null", phase: null },
+              },
+              {
+                type: "response.output_text.done",
+                item_id: "msg_null",
+                content_index: 0,
+                text: "Nullable.",
+              },
+              {
+                type: "response.output_item.done",
+                item: { type: "message", id: "msg_null", phase: null },
+              },
+              {
+                type: "response.output_item.added",
+                item: { type: "message", id: "msg_unphased" },
+              },
+              {
+                type: "response.output_text.done",
+                item_id: "msg_unphased",
+                content_index: 0,
+                text: "Unphased.",
+              },
+              {
+                type: "response.output_item.done",
+                item: { type: "message", id: "msg_unphased" },
+              },
+              { type: "response.completed", response: { id: "resp_1" } },
+            ),
+          ),
+        ),
+      )
+
+      expect(response.message.content).toEqual([
+        {
+          type: "text",
+          text: "First.",
+          providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
+        },
+        {
+          type: "text",
+          text: "Second.",
+          providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
+        },
+        {
+          type: "text",
+          text: "Third.",
+          providerMetadata: { openai: { itemId: "msg_commentary_2", phase: "commentary" } },
+        },
+        {
+          type: "text",
+          text: "Final.",
+          providerMetadata: {
+            openai: {
+              itemId: "openai-text-0",
+              phase: "final_answer",
+              annotations: [
+                {
+                  type: "url_citation",
+                  url: "https://example.com",
+                  title: "Example",
+                  start_index: 0,
+                  end_index: 6,
+                },
+              ],
+            },
+          },
+        },
+        {
+          type: "text",
+          text: "Nullable.",
+          providerMetadata: { openai: { itemId: "msg_null", phase: null } },
+        },
+        {
+          type: "text",
+          text: "Unphased.",
+          providerMetadata: { openai: { itemId: "msg_unphased" } },
+        },
+      ])
+
+      expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([
+        {
+          type: "text-start",
+          id: "msg_commentary",
+          providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
+        },
+        { type: "text-delta", id: "msg_commentary", text: "First." },
+        {
+          type: "text-end",
+          id: "msg_commentary",
+          providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
+        },
+        {
+          type: "text-start",
+          id: "openai-text-0",
+          providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
+        },
+        { type: "text-delta", id: "openai-text-0", text: "Second." },
+        {
+          type: "text-end",
+          id: "openai-text-0",
+          providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
+        },
+        {
+          type: "text-start",
+          id: "msg_commentary_2",
+          providerMetadata: { openai: { itemId: "msg_commentary_2", phase: "commentary" } },
+        },
+        { type: "text-delta", id: "msg_commentary_2", text: "Thi" },
+        { type: "text-delta", id: "msg_commentary_2", text: "rd." },
+        {
+          type: "text-end",
+          id: "msg_commentary_2",
+          providerMetadata: { openai: { itemId: "msg_commentary_2", phase: "commentary" } },
+        },
+        {
+          type: "text-start",
+          id: "openai-text-1",
+          providerMetadata: { openai: { itemId: "openai-text-0" } },
+        },
+        { type: "text-delta", id: "openai-text-1", text: "Final." },
+        {
+          type: "text-end",
+          id: "openai-text-1",
+          providerMetadata: {
+            openai: {
+              itemId: "openai-text-0",
+              phase: "final_answer",
+              annotations: [
+                {
+                  type: "url_citation",
+                  url: "https://example.com",
+                  title: "Example",
+                  start_index: 0,
+                  end_index: 6,
+                },
+              ],
+            },
+          },
+        },
+        {
+          type: "text-start",
+          id: "msg_null",
+          providerMetadata: { openai: { itemId: "msg_null", phase: null } },
+        },
+        { type: "text-delta", id: "msg_null", text: "Nullable." },
+        {
+          type: "text-end",
+          id: "msg_null",
+          providerMetadata: { openai: { itemId: "msg_null", phase: null } },
+        },
+        {
+          type: "text-start",
+          id: "msg_unphased",
+          providerMetadata: { openai: { itemId: "msg_unphased" } },
+        },
+        { type: "text-delta", id: "msg_unphased", text: "Unphased." },
+        {
+          type: "text-end",
+          id: "msg_unphased",
+          providerMetadata: { openai: { itemId: "msg_unphased" } },
+        },
+      ])
+
+      const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
+        LLM.request({ model, messages: [response.message] }),
+      )
+      expect(prepared.body.input).toEqual([
+        {
+          type: "message",
+          id: "msg_commentary",
+          status: "completed",
+          role: "assistant",
+          phase: "commentary",
+          content: [
+            { type: "output_text", text: "First.", annotations: [] },
+            { type: "output_text", text: "Second.", annotations: [] },
+          ],
+        },
+        {
+          type: "message",
+          id: "msg_commentary_2",
+          status: "completed",
+          role: "assistant",
+          phase: "commentary",
+          content: [{ type: "output_text", text: "Third.", annotations: [] }],
+        },
+        {
+          type: "message",
+          id: "openai-text-0",
+          status: "completed",
+          role: "assistant",
+          phase: "final_answer",
+          content: [
+            {
+              type: "output_text",
+              text: "Final.",
+              annotations: [
+                {
+                  type: "url_citation",
+                  url: "https://example.com",
+                  title: "Example",
+                  start_index: 0,
+                  end_index: 6,
+                },
+              ],
+            },
+          ],
+        },
+        {
+          type: "message",
+          id: "msg_null",
+          status: "completed",
+          role: "assistant",
+          phase: null,
+          content: [{ type: "output_text", text: "Nullable.", annotations: [] }],
+        },
+        {
+          type: "message",
+          id: "msg_unphased",
+          status: "completed",
+          role: "assistant",
+          content: [{ type: "output_text", text: "Unphased.", annotations: [] }],
+        },
+      ])
+    }),
+  )
+
   it.effect("parses reasoning summary stream fixtures", () =>
     Effect.gen(function* () {
       const body = sseEvents(
@@ -947,7 +1336,7 @@ describe("OpenAI Responses route", () => {
                     encrypted_content: "encrypted-state",
                     summary: [{ type: "summary_text", text: "Checked the previous diff." }],
                   },
-                  { role: "assistant", content: [{ type: "output_text", text: "The parser changed." }] },
+                  { role: "assistant", content: "The parser changed." },
                   { role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
                 ],
               })
@@ -995,13 +1384,69 @@ describe("OpenAI Responses route", () => {
       )
 
       expect(prepared.body.input).toEqual([
-        { role: "assistant", content: [{ type: "output_text", text: "Before." }] },
+        { role: "assistant", content: "Before." },
         {
           type: "reasoning",
           encrypted_content: "encrypted-state",
           summary: [{ type: "summary_text", text: "Checked order." }],
         },
-        { role: "assistant", content: [{ type: "output_text", text: "After." }] },
+        { role: "assistant", content: "After." },
+      ])
+    }),
+  )
+
+  it.effect("round-trips assistant message phases", () =>
+    Effect.gen(function* () {
+      const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
+        LLM.request({
+          model,
+          messages: [
+            Message.assistant([
+              {
+                type: "text",
+                text: "Checking first.",
+                providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
+              },
+              {
+                type: "text",
+                text: "Still checking.",
+                providerMetadata: { openai: { itemId: "msg_commentary_2", phase: "commentary" } },
+              },
+              {
+                type: "text",
+                text: "Finished.",
+                providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } },
+              },
+            ]),
+          ],
+        }),
+      )
+
+      expect(prepared.body.input).toEqual([
+        {
+          type: "message",
+          id: "msg_commentary",
+          status: "completed",
+          role: "assistant",
+          phase: "commentary",
+          content: [{ type: "output_text", text: "Checking first.", annotations: [] }],
+        },
+        {
+          type: "message",
+          id: "msg_commentary_2",
+          status: "completed",
+          role: "assistant",
+          phase: "commentary",
+          content: [{ type: "output_text", text: "Still checking.", annotations: [] }],
+        },
+        {
+          type: "message",
+          id: "msg_final",
+          status: "completed",
+          role: "assistant",
+          phase: "final_answer",
+          content: [{ type: "output_text", text: "Finished.", annotations: [] }],
+        },
       ])
     }),
   )
@@ -1120,7 +1565,13 @@ describe("OpenAI Responses route", () => {
                   },
                 },
               },
-              { type: "text", text: "The parser changed." },
+              {
+                type: "text",
+                text: "The parser changed.",
+                providerMetadata: {
+                  openai: { itemId: "msg_1", phase: "final_answer", status: "completed" },
+                },
+              },
             ]),
             Message.user("Summarize it."),
           ],
@@ -1131,7 +1582,7 @@ describe("OpenAI Responses route", () => {
       expect(prepared.body).toMatchObject({
         input: [
           { role: "user", content: [{ type: "input_text", text: "What changed?" }] },
-          { role: "assistant", content: [{ type: "output_text", text: "The parser changed." }] },
+          { role: "assistant", content: "The parser changed.", phase: "final_answer" },
           { role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
         ],
         store: false,

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

@@ -119,7 +119,7 @@ const storedSession = {
 
 const openAIResponses = {
   user: (text: string) => ({ role: "user", content: [{ type: "input_text", text }] }),
-  assistant: (text: string) => ({ role: "assistant", content: [{ type: "output_text", text }] }),
+  assistant: (text: string) => ({ role: "assistant", content: text }),
   openaiReasoning: (text: string, encryptedContent: string) => ({
     type: "reasoning",
     encrypted_content: encryptedContent,

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů