Files
TJWaterAgent/patches/opencode-1.18.13-message-phase.patch

187 lines
6.6 KiB
Diff

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<typeof OpenAIResponsesUsage>
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<Record<string, ReasoningStreamItem>>
+ readonly textMetadata: Readonly<Record<string, ProviderMetadata>>
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<string>(),
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(