Просмотр исходного кода

fix(llm): revert response message phases (#38761)

Aiden Cline 1 месяц назад
Родитель
Сommit
7840562d1b

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

@@ -46,12 +46,8 @@ 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,
@@ -82,19 +78,7 @@ 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.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),
-  }),
+  Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenAIResponsesOutputText) }),
   OpenAIResponsesReasoningItem,
   OpenAIResponsesItemReference,
   Schema.Struct({
@@ -210,9 +194,7 @@ 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>
 
@@ -230,9 +212,7 @@ 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(
@@ -257,18 +237,10 @@ 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 {
@@ -326,26 +298,6 @@ 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
@@ -416,49 +368,17 @@ 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(
-          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 } : {}),
-              },
-        )
+        input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) })
         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
         }
@@ -509,20 +429,6 @@ 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
     }
 
@@ -706,134 +612,15 @@ 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 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 onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
+  if (!event.delta) return [state, NO_EVENTS]
   const events: LLMEvent[] = []
-  const lifecycle = Lifecycle.textStart(
-    closeOtherMessageContent(ensured.state, events, ensured.item, ensured.index),
-    events,
-    ensured.content.id,
-    ensured.item.providerMetadata,
-  )
   return [
-    {
-      ...updateMessageContent(ensured.state, ensured.itemID, ensured.index, {
-        ...ensured.content,
-        text: ensured.content.text + text,
-      }),
-      lifecycle: Lifecycle.textDelta(lifecycle, events, ensured.content.id, text),
-    },
+    { ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, event.item_id ?? "text-0", event.delta) },
     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[] = []
@@ -868,23 +655,6 @@ 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 [
@@ -1042,32 +812,6 @@ 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]
@@ -1195,7 +939,6 @@ 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")
@@ -1225,9 +968,6 @@ 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),
     }),

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

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

Разница между файлами не показана из-за своего большого размера
+ 0 - 23
packages/llm/test/fixtures/recordings/openai-responses-phase/round-trips-commentary-into-a-final-answer.json


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json


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

@@ -1,89 +0,0 @@
-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()
-    }),
-  )
-})

+ 8 - 459
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: "After." },
+        { role: "assistant", content: [{ type: "output_text", text: "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: "It shows a small test image." },
+          { role: "assistant", content: [{ type: "output_text", text: "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: "Paris is 22 degrees." },
+          { role: "assistant", content: [{ type: "output_text", text: "Paris is 22 degrees." }] },
           {
             role: "user",
             content: [{ type: "input_text", text: "Continue from this conversation in one short sentence." }],
@@ -754,395 +754,6 @@ 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(
@@ -1336,7 +947,7 @@ describe("OpenAI Responses route", () => {
                     encrypted_content: "encrypted-state",
                     summary: [{ type: "summary_text", text: "Checked the previous diff." }],
                   },
-                  { role: "assistant", content: "The parser changed." },
+                  { role: "assistant", content: [{ type: "output_text", text: "The parser changed." }] },
                   { role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
                 ],
               })
@@ -1384,69 +995,13 @@ describe("OpenAI Responses route", () => {
       )
 
       expect(prepared.body.input).toEqual([
-        { role: "assistant", content: "Before." },
+        { role: "assistant", content: [{ type: "output_text", text: "Before." }] },
         {
           type: "reasoning",
           encrypted_content: "encrypted-state",
           summary: [{ type: "summary_text", text: "Checked order." }],
         },
-        { 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: [] }],
-        },
+        { role: "assistant", content: [{ type: "output_text", text: "After." }] },
       ])
     }),
   )
@@ -1565,13 +1120,7 @@ describe("OpenAI Responses route", () => {
                   },
                 },
               },
-              {
-                type: "text",
-                text: "The parser changed.",
-                providerMetadata: {
-                  openai: { itemId: "msg_1", phase: "final_answer", status: "completed" },
-                },
-              },
+              { type: "text", text: "The parser changed." },
             ]),
             Message.user("Summarize it."),
           ],
@@ -1582,7 +1131,7 @@ describe("OpenAI Responses route", () => {
       expect(prepared.body).toMatchObject({
         input: [
           { role: "user", content: [{ type: "input_text", text: "What changed?" }] },
-          { role: "assistant", content: "The parser changed.", phase: "final_answer" },
+          { role: "assistant", content: [{ type: "output_text", text: "The parser changed." }] },
           { 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: text }),
+  assistant: (text: string) => ({ role: "assistant", content: [{ type: "output_text", text }] }),
   openaiReasoning: (text: string, encryptedContent: string) => ({
     type: "reasoning",
     encrypted_content: encryptedContent,

Некоторые файлы не были показаны из-за большого количества измененных файлов