-
Notifications
You must be signed in to change notification settings - Fork 0
fix: propagate provider finish errors to headless runs #113
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -252,6 +252,24 @@ export namespace SessionProcessor { | |||||
| input.assistantMessage.cost += usage.cost | ||||||
| input.assistantMessage.tokens = usage.tokens | ||||||
| input.assistantMessage.usageStatus = usage.usageStatus | ||||||
| // Providers can end a successful HTTP stream with a failed | ||||||
| // model turn. Preserve its parts and usage, but use the same | ||||||
| // failure lifecycle as a thrown, nonretryable provider error. | ||||||
| if (value.finishReason === "error" || value.finishReason === "content-filter") { | ||||||
| log.error("provider finish", { | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 "other" finish abnormal terminations still exit 0.
Suggested change
🤖 Fix with your agentWhy this mattersOnly "error" and "content-filter" convert to a failed turn; a stream that terminates abnormally with a normalized "other" (Gemini OTHER, and unverified Gemini safety reasons like PROHIBITED_CONTENT/LANGUAGE depending on SDK mapping) still persists status "completed" and headless runs exit 0 — the same silent-CI-failure class issue #108 targets. The new test enshrines OTHER → exit 0, and EVENTS.md documents "other nonempty finish" as non-failure, so this is a deliberate policy — but the residual gap means some blocked/malformed responses remain indistinguishable from success in CI. Worth either verifying every Gemini failure finishReason maps to content-filter/error in the pinned SDK, or surfacing "other" terminations via a non-fatal warning event / failure when the turn produced no output. // Providers can end a successful HTTP stream with a failed\n // model turn. Preserve its parts and usage, but use the same\n // failure lifecycle as a thrown, nonretryable provider error.\n if (value.finishReason === "error" || value.finishReason === "content-filter") {\n log.error("provider finish", {\n sessionID: input.sessionID,\n messageID: input.assistantMessage.id,\n finishReason: value.finishReason,\n }) |
||||||
| sessionID: input.sessionID, | ||||||
| messageID: input.assistantMessage.id, | ||||||
| finishReason: value.finishReason, | ||||||
| }) | ||||||
| input.assistantMessage.error = new MessageV2.APIError({ | ||||||
| message: | ||||||
| value.finishReason === "content-filter" | ||||||
| ? "The provider blocked the response with a content filter." | ||||||
| : "The provider ended the response with an error finish reason.", | ||||||
| isRetryable: false, | ||||||
| metadata: { finishReason: value.finishReason }, | ||||||
| }).toObject() | ||||||
| } | ||||||
| await Session.updatePart({ | ||||||
| id: Identifier.ascending("part"), | ||||||
| reason: value.finishReason, | ||||||
|
|
@@ -263,6 +281,13 @@ export namespace SessionProcessor { | |||||
| cost: usage.cost, | ||||||
| }) | ||||||
| await Session.updateMessage(input.assistantMessage) | ||||||
| if (input.assistantMessage.error) { | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Error break may kill interactive sessions. 🤖 Fix with your agentWhy this mattersOn a failed finish reason the code publishes Session.Event.Error and unconditionally breaks the loop. In interactive (non-headless) sessions a content-filter turn previously ended the turn and returned the prompt; now the session loop terminates, and a TUI listener for Session.Event.Error may additionally surface the error already persisted on the message (double handling). The thrown-error path goes through the catch block instead, so the two failure lifecycles this change claims to unify may still diverge (the catch path may recover/continue in interactive mode). Only the headless path is tested. cost: usage.cost,
})
await Session.updateMessage(input.assistantMessage)
if (input.assistantMessage.error) {
await Bus.publish(Session.Event.Error, {
sessionID: input.sessionID,
error: input.assistantMessage.error,
})
break
}
if (snapshot) { |
||||||
| await Bus.publish(Session.Event.Error, { | ||||||
| sessionID: input.sessionID, | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Session.Event.Error publish awaited inconsistently.
Suggested change
🤖 Fix with your agentWhy this mattersThe new finish-step failure path awaits Bus.publish(Session.Event.Error) (processor.ts:286), but the two sibling sites publishing the identical event in the same function remain fire-and-forget: the max-retry branch (~L396) and the terminal catch branch (~L413) call Bus.publish without await. Since this PR's stated goal is consistent failure events and headless consumers depend on session_error flushing before idle/exit, the same event should be published the same way at all three sites; un-awaited publishes rely on incidental later awaits to flush. await Session.updateMessage(input.assistantMessage)\n if (input.assistantMessage.error) {\n await Bus.publish(Session.Event.Error, {\n sessionID: input.sessionID,\n error: input.assistantMessage.error,\n })\n break\n }\n if (snapshot) { |
||||||
| error: input.assistantMessage.error, | ||||||
| }) | ||||||
| break | ||||||
| } | ||||||
| if (snapshot) { | ||||||
| const patch = await Snapshot.patch(snapshot) | ||||||
| if (patch.files.length) { | ||||||
|
|
@@ -349,7 +374,7 @@ export namespace SessionProcessor { | |||||
| }) | ||||||
| continue | ||||||
| } | ||||||
| if (needsCompaction) break | ||||||
| if (needsCompaction || input.assistantMessage.error) break | ||||||
| } | ||||||
| } catch (e: any) { | ||||||
| log.error("process", { | ||||||
|
|
@@ -368,7 +393,7 @@ export namespace SessionProcessor { | |||||
| input.assistantMessage.error = new NamedError.Unknown({ | ||||||
| message: `Max retry attempts (${SessionRetry.MAX_RETRY_ATTEMPTS}) reached: ${retry}`, | ||||||
| }).toObject() | ||||||
| Bus.publish(Session.Event.Error, { | ||||||
| await Bus.publish(Session.Event.Error, { | ||||||
| sessionID: input.assistantMessage.sessionID, | ||||||
| error: input.assistantMessage.error, | ||||||
| }) | ||||||
|
|
@@ -385,7 +410,7 @@ export namespace SessionProcessor { | |||||
| continue | ||||||
| } | ||||||
| input.assistantMessage.error = error | ||||||
| Bus.publish(Session.Event.Error, { | ||||||
| await Bus.publish(Session.Event.Error, { | ||||||
| sessionID: input.assistantMessage.sessionID, | ||||||
| error: input.assistantMessage.error, | ||||||
| }) | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,216 @@ | ||||||
| import { describe, expect, test } from "bun:test" | ||||||
| import path from "path" | ||||||
| import { createGoogleGenerativeAI } from "@ai-sdk/google" | ||||||
| import { tmpdir } from "../fixture/fixture" | ||||||
|
|
||||||
| const entry = path.resolve(import.meta.dir, "../../src/index.ts") | ||||||
|
|
||||||
| describe("headless provider finish reasons (#108)", () => { | ||||||
| test.each([ | ||||||
| ["MALFORMED_FUNCTION_CALL", "error", 1, false, false], | ||||||
| ["MALFORMED_FUNCTION_CALL", "error", 1, true, true], | ||||||
| ["SAFETY", "content-filter", 1, false, false], | ||||||
| ["RECITATION", "content-filter", 1, false, false], | ||||||
| ["BLOCKLIST", "content-filter", 1, false, false], | ||||||
| ["SPII", "content-filter", 1, false, false], | ||||||
| ["PROHIBITED_CONTENT", "content-filter", 1, false, false], | ||||||
| ["FINISH_REASON_UNSPECIFIED", "other", 0, false, false], | ||||||
| ["OTHER", "other", 0, false, false], | ||||||
| ["STOP", "stop", 0, false, false], | ||||||
| ["STOP", "stop", 0, true, false], | ||||||
| ["MAX_TOKENS", "length", 0, false, false], | ||||||
| ] as const)( | ||||||
| "normal Gemini stream ending %s", | ||||||
| async (reason, finish, code, tool, partial) => { | ||||||
| await using tmp = await tmpdir() | ||||||
| let calls = 0 | ||||||
| const server = Bun.serve({ | ||||||
| port: 0, | ||||||
| fetch(): Response { | ||||||
| calls++ | ||||||
| const chunks = | ||||||
| tool && calls === 1 | ||||||
| ? [ | ||||||
| { | ||||||
| candidates: [ | ||||||
| { | ||||||
| index: 0, | ||||||
| content: { | ||||||
| role: "model", | ||||||
| parts: [ | ||||||
| { functionCall: { name: "read", args: { filePath: path.join(tmp.path, "aictrl.json") } } }, | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ⚪ fetch handler references tmp before declaration.
Suggested change
🤖 Fix with your agentWhy this mattersThe Bun.serve fetch handler references const server = Bun.serve({\n port: 0,\n fetch(): Response {\n calls++\n const chunks =\n tool && calls === 1\n ? [\n {\n candidates: [\n {\n index: 0,\n content: {\n role: \"model\",\n parts: [\n { functionCall: { name: \"read\", args: { filePath: path.join(tmp.path, \"aictrl.json\") } } }, |
||||||
| ], | ||||||
| }, | ||||||
| finishReason: "STOP", | ||||||
| }, | ||||||
| ], | ||||||
| usageMetadata: { promptTokenCount: 7, candidatesTokenCount: 3, totalTokenCount: 10 }, | ||||||
| }, | ||||||
| ] | ||||||
| : [ | ||||||
| { | ||||||
| candidates: [ | ||||||
| { | ||||||
| index: 0, | ||||||
| content: { | ||||||
| role: "model", | ||||||
| parts: [ | ||||||
| { text: "Checking the input.", thought: true }, | ||||||
| ...(partial ? [{ text: "Partial review." }] : []), | ||||||
| ], | ||||||
| }, | ||||||
| }, | ||||||
| ], | ||||||
| }, | ||||||
| { | ||||||
| candidates: [{ index: 0, content: { role: "model", parts: [] }, finishReason: reason }], | ||||||
| usageMetadata: { promptTokenCount: 7, candidatesTokenCount: 3, totalTokenCount: 10 }, | ||||||
| }, | ||||||
| ] | ||||||
| return new Response(chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join(""), { | ||||||
| headers: { "content-type": "text/event-stream" }, | ||||||
| }) | ||||||
| }, | ||||||
| }) | ||||||
| await Bun.write( | ||||||
| path.join(tmp.path, "aictrl.json"), | ||||||
| JSON.stringify({ | ||||||
| provider: { | ||||||
| fixture: { | ||||||
| npm: "@ai-sdk/google", | ||||||
| options: { apiKey: "fixture", baseURL: `http://127.0.0.1:${server.port}` }, | ||||||
| models: { "gemini-fixture": { name: "fixture", limit: { context: 100000, output: 1000 } } }, | ||||||
| }, | ||||||
| }, | ||||||
| agent: { title: { disable: true } }, | ||||||
| }), | ||||||
| ) | ||||||
| const proc = Bun.spawn( | ||||||
| [ | ||||||
| "bun", | ||||||
| "run", | ||||||
| "--conditions=browser", | ||||||
| entry, | ||||||
| "run", | ||||||
| "--format", | ||||||
| "json", | ||||||
| "--thinking", | ||||||
| "--model", | ||||||
| "fixture/gemini-fixture", | ||||||
| "Check this input.", | ||||||
| ], | ||||||
| { | ||||||
| cwd: tmp.path, | ||||||
| env: { | ||||||
| ...process.env, | ||||||
| AICTRL_DISABLE_DEFAULT_PLUGINS: "true", | ||||||
| AICTRL_DISABLE_MODELS_FETCH: "true", | ||||||
| AICTRL_DISABLE_AUTOCOMPACT: "true", | ||||||
| }, | ||||||
| stdout: "pipe", | ||||||
| stderr: "pipe", | ||||||
| }, | ||||||
| ) | ||||||
| const timeout = setTimeout(() => proc.kill("SIGKILL"), 15000) | ||||||
| try { | ||||||
| const [stdout, stderr, exit] = await Promise.all([ | ||||||
| new Response(proc.stdout).text(), | ||||||
| new Response(proc.stderr).text(), | ||||||
| proc.exited, | ||||||
| ]) | ||||||
| const events = stdout | ||||||
| .split("\n") | ||||||
| .filter((line) => line.startsWith("{")) | ||||||
| .map((line) => JSON.parse(line)) | ||||||
| expect(exit, stderr + stdout).toBe(code) | ||||||
| expect(calls).toBe(tool ? 2 : 1) | ||||||
| const message = events.filter((event) => event.type === "message_complete") | ||||||
| expect(message, stdout).toHaveLength(tool ? 2 : 1) | ||||||
| if (tool) { | ||||||
| expect(message[0].finish).toBe("tool-calls") | ||||||
| expect(events.filter((event) => event.type === "tool_use")).toHaveLength(1) | ||||||
| } | ||||||
| expect(message.at(-1).finish).toBe(finish) | ||||||
| expect(message.at(-1).status).toBe(code ? "error" : "completed") | ||||||
| expect(message.at(-1).usageStatus).toBe("reported") | ||||||
| expect(message.at(-1).tokens).toMatchObject({ input: 7, output: 3 }) | ||||||
| expect(events.filter((event) => event.type === "reasoning")).toHaveLength(1) | ||||||
| expect(events.filter((event) => event.type === "text")).toHaveLength(partial ? 1 : 0) | ||||||
| expect(events.filter((event) => event.type === "session_complete")).toHaveLength(1) | ||||||
| expect(events.filter((event) => event.type === "invocation_complete")).toHaveLength(1) | ||||||
| const invocation = events.find((event) => event.type === "invocation_complete") | ||||||
| expect(invocation.status).toBe(code ? "error" : "completed") | ||||||
| for (const event of events.filter((event) => | ||||||
| ["message_complete", "session_error", "session_complete"].includes(event.type), | ||||||
| )) { | ||||||
| expect(event.sessionID).toBe(invocation.sessionID) | ||||||
| expect(event.invocationID).toBe(invocation.invocationID) | ||||||
| } | ||||||
| expect(events.filter((event) => event.type === "session_error")).toHaveLength(code ? 1 : 0) | ||||||
| if (code) { | ||||||
| expect(events.find((event) => event.type === "session_error").reason).toBe("provider") | ||||||
| expect(events.find((event) => event.type === "session_complete").error).toBeTruthy() | ||||||
| expect(events.filter((event) => event.type === "error")).toHaveLength(1) | ||||||
| const failure = events.find((event) => event.type === "error") | ||||||
| expect(failure.error).toMatchObject({ | ||||||
| name: "APIError", | ||||||
| data: { isRetryable: false, metadata: { finishReason: finish } }, | ||||||
| }) | ||||||
| expect(failure.sessionID).toBe(invocation.sessionID) | ||||||
| expect(failure.invocationID).toBe(invocation.invocationID) | ||||||
| expect(events.findIndex((event) => event.type === "session_error")).toBeLessThan( | ||||||
| events.findIndex((event) => event.type === "session_complete"), | ||||||
| ) | ||||||
| } | ||||||
| } finally { | ||||||
| clearTimeout(timeout) | ||||||
| proc.kill("SIGKILL") | ||||||
| server.stop(true) | ||||||
| } | ||||||
| }, | ||||||
| 20000, | ||||||
| ) | ||||||
| }) | ||||||
|
|
||||||
| describe("pinned Google adapter finish mappings", () => { | ||||||
| test.each([ | ||||||
| ["STOP", "stop"], | ||||||
| ["MAX_TOKENS", "length"], | ||||||
| ["IMAGE_SAFETY", "content-filter"], | ||||||
| ["RECITATION", "content-filter"], | ||||||
| ["SAFETY", "content-filter"], | ||||||
| ["BLOCKLIST", "content-filter"], | ||||||
| ["PROHIBITED_CONTENT", "content-filter"], | ||||||
| ["SPII", "content-filter"], | ||||||
| ["MALFORMED_FUNCTION_CALL", "error"], | ||||||
| ["OTHER", "other"], | ||||||
| ["FINISH_REASON_UNSPECIFIED", "other"], | ||||||
| ["LANGUAGE", "unknown"], | ||||||
| ])("%s → %s", async (raw, normalized) => { | ||||||
| const provider = createGoogleGenerativeAI({ | ||||||
| apiKey: "fixture", | ||||||
| fetch: Object.assign( | ||||||
| async () => | ||||||
| new Response( | ||||||
| `data: ${JSON.stringify({ | ||||||
| candidates: [{ index: 0, content: { role: "model", parts: [] }, finishReason: raw }], | ||||||
| usageMetadata: { promptTokenCount: 7, candidatesTokenCount: 3, totalTokenCount: 10 }, | ||||||
| })}\n\n`, | ||||||
| { headers: { "content-type": "text/event-stream" } }, | ||||||
| ), | ||||||
| { preconnect: globalThis.fetch.preconnect }, | ||||||
| ), | ||||||
| }) | ||||||
| const response = await provider("gemini-fixture").doStream({ | ||||||
| prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }], | ||||||
| }) | ||||||
| const reader = response.stream.getReader() | ||||||
| let finish: string | undefined | ||||||
| while (true) { | ||||||
| const { done, value } = await reader.read() | ||||||
| if (done) break | ||||||
| if (value.type === "finish") finish = value.finishReason | ||||||
| } | ||||||
| expect(finish).toBe(normalized) | ||||||
| }) | ||||||
| }) | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Finish reasons other than error/content-filter still exit 0.
🤖 Fix with your agent
Why this matters
Only "error" and "content-filter" are treated as failures. Gemini emits other failure finishReasons (RECITATION, BLOCKLIST, SPII, OTHER) that may normalize to "other"/"unknown"; those turns are still persisted as success and headless runs exit 0, so #108-style silent failures remain for a subset of blocked/malformed responses. The tests cover only MALFORMED_FUNCTION_CALL and SAFETY mappings.