diff --git a/package.json b/package.json index 15725c8..4378766 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "AI-powered development tool", "private": true, "type": "module", - "packageManager": "bun@1.3.14", + "packageManager": "bun@1.3.13", "scripts": { "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "dev:desktop": "bun --cwd packages/desktop dev", diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 4936d31..53138af 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -177,6 +177,7 @@ type OpenAIResponsesUsage = Schema.Schema.Type const OpenAIResponsesStreamItem = Schema.Struct({ type: Schema.String, id: Schema.optional(Schema.String), + phase: optionalNull(Schema.String), call_id: Schema.optional(Schema.String), name: Schema.optional(Schema.String), arguments: Schema.optional(Schema.String), @@ -238,6 +239,7 @@ interface ParserState { readonly hasFunctionCall: boolean readonly lifecycle: Lifecycle.State readonly reasoningItems: Readonly> + readonly textMetadata: Readonly> readonly store: boolean | undefined } @@ -614,9 +616,19 @@ const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "re const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { if (!event.delta) return [state, NO_EVENTS] + const itemID = event.item_id ?? "text-0" const events: LLMEvent[] = [] return [ - { ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, event.item_id ?? "text-0", event.delta) }, + { + ...state, + lifecycle: Lifecycle.textDelta( + state.lifecycle, + events, + itemID, + event.delta, + state.textMetadata[itemID], + ), + }, events, ] } @@ -655,6 +667,22 @@ 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 phase = item.phase === "commentary" || item.phase === "final_answer" ? item.phase : undefined + return [ + { + ...state, + textMetadata: { + ...state.textMetadata, + [item.id]: openaiMetadata({ + itemId: item.id, + ...(phase ? { phase } : {}), + }), + }, + }, + NO_EVENTS, + ] + } if (item && isReasoningItem(item)) { const events: LLMEvent[] = [] return [ @@ -812,6 +840,20 @@ 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 lifecycle = Lifecycle.textEnd(state.lifecycle, events, item.id, state.textMetadata[item.id]) + const { [item.id]: _removed, ...textMetadata } = state.textMetadata + return [ + { + ...state, + lifecycle, + textMetadata, + }, + 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] @@ -969,6 +1011,7 @@ export const protocol = Protocol.make({ tools: ToolStream.empty(), lifecycle: Lifecycle.initial(), reasoningItems: {}, + textMetadata: {}, store: OpenAIOptions.store(request), }), step, diff --git a/packages/llm/src/protocols/utils/lifecycle.ts b/packages/llm/src/protocols/utils/lifecycle.ts index eb6c95d..64248be 100644 --- a/packages/llm/src/protocols/utils/lifecycle.ts +++ b/packages/llm/src/protocols/utils/lifecycle.ts @@ -14,13 +14,22 @@ 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 textDelta = ( + state: State, + events: LLMEvent[], + id: string, + text: string, + providerMetadata?: ProviderMetadata, +): State => { const stepped = stepStart(state, events) if (stepped.text.has(id)) { - events.push(LLMEvent.textDelta({ id, text })) + events.push(LLMEvent.textDelta({ id, text, providerMetadata })) return stepped } - events.push(LLMEvent.textStart({ id }), LLMEvent.textDelta({ id, text })) + events.push( + LLMEvent.textStart({ id, providerMetadata }), + LLMEvent.textDelta({ id, text, providerMetadata }), + ) return { ...stepped, text: new Set([...stepped.text, id]) } } diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index cd8bad5..89ae576 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -754,6 +754,45 @@ describe("OpenAI Responses route", () => { }), ) + it.effect("preserves assistant message phase in text provider metadata", () => + Effect.gen(function* () { + const message = { + type: "message", + id: "msg_final", + phase: "final_answer", + } + const body = sseEvents( + { type: "response.output_item.added", item: message }, + { type: "response.output_text.delta", item_id: "msg_final", delta: "Final" }, + { type: "response.output_item.done", item: message }, + { type: "response.completed", response: { id: "resp_1" } }, + ) + + const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + const metadata = { + openai: { + itemId: "msg_final", + phase: "final_answer", + }, + } + + expect(response.text).toBe("Final") + expect(response.events).toMatchObject([ + { type: "step-start", index: 0 }, + { type: "text-start", id: "msg_final", providerMetadata: metadata }, + { + type: "text-delta", + id: "msg_final", + text: "Final", + providerMetadata: metadata, + }, + { type: "text-end", id: "msg_final", providerMetadata: metadata }, + { type: "step-finish", index: 0, reason: "stop" }, + { type: "finish", reason: "stop" }, + ]) + }), + ) + it.effect("parses reasoning summary stream fixtures", () => Effect.gen(function* () { const body = sseEvents(