fix(agent): stabilize sandboxed streaming workflows
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
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(
|
||||
Reference in New Issue
Block a user