Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Fixes

- **Provider error finishes fail headless execution** — Normal streams ending in `error` or `content-filter` now persist a structured provider failure, emit consistent failure events, and exit nonzero while retaining partial output and usage. Empty successful responses remain successful. (#108)

### Features

- **GPT-5.6 Codex models** — Added OpenAI's Sol, Terra, and Luna models with API and subscription-backed reasoning effort variants, including the Codex-only `ultra` alias for Sol and Terra.
Expand Down
10 changes: 10 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ https://github.com/anomalyco/models.dev
bun dev
```

### Headless release regression checks

From `packages/cli`, run:

```bash
bun test test/cli/run-provider-finish.test.ts test/cli/run-signal-cancellation.test.ts test/cli/classify-session-error.test.ts
```

The provider fixture uses real Gemini SSE responses and the pinned SDK in a headless subprocess. It checks failed finishes, content filtering, empty success, tool calls, partial output, and output limits. The cancellation tests verify that signals keep their distinct failure classification and flush terminal events before exit.

### Running against a different directory

By default, `bun dev` runs Aictrl in the `packages/aictrl` directory. To run it against a different directory or repository:
Expand Down
20 changes: 20 additions & 0 deletions EVENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,26 @@ For compatibility with sessions written before usage provenance was persisted, a
- `ratio` (number) — `used / limit` (≥0; may exceed 1 if usage exceeds the model's registered limit). A value approaching or exceeding 1 signals context-exhaustion risk.
- `null` — emitted when the model's context limit is not known (e.g. unregistered custom endpoint), or usage is missing.

**Terminal reason semantics (schema v1)**

A provider can finish an HTTP stream normally while reporting a failed model turn. `error` and `content-filter` finishes persist a nonretryable `APIError` with `data.metadata.finishReason`; they emit `message_complete.status: "error"`, `session_error.reason: "provider"`, a populated `session_complete.error`, and `invocation_complete.status: "error"`. Headless execution exits 1 after flushing output. Partial text, completed tools, and reported usage remain available. No automatic recovery is attempted.

| Finish or termination | Behavior |
| ----------------------------- | -------------------------------------------------------------------------------------------- |
| `error` | Failed model turn; session/invocation failure and exit 1. |
| `content-filter` | Failed model turn with a visible content-filter message; exit 1. |
| `stop` | Completed turn, including empty output. |
| `tool-calls` | Completed model turn; run tools and continue the session loop. |
| `length` | Existing behavior: completed turn; preserve the reason so consumers can identify truncation. |
| `unknown` | Existing behavior: continue the session loop. |
| Other nonempty finish | Existing behavior: end the loop without inferring failure from an unfamiliar reason. |
| Thrown provider error | Existing retry/error handling; unrecoverable failures emit the failure lifecycle. |
| Cancellation / stream timeout | Existing cancellation and timeout lifecycle; not reclassified as a provider finish error. |

With pinned Google SDK 2.0.54, `IMAGE_SAFETY`, `RECITATION`, `SAFETY`, `BLOCKLIST`, `PROHIBITED_CONTENT`, and `SPII` map to `content-filter`; `MALFORMED_FUNCTION_CALL` maps to `error`. `OTHER` and `FINISH_REASON_UNSPECIFIED` map to `other`, while `LANGUAGE` maps to `unknown`. These last mappings retain the behavior above; they do not prove successful task completion. Consumers can distinguish `other` from `stop` using `message_complete.finish`.

Errors are attributed to the originating session. A child error alone does not change the primary session's exit status if the primary agent handles it successfully.

### `text`

Emitted when a text block from the assistant is complete.
Expand Down
7 changes: 4 additions & 3 deletions packages/cli/src/cli/cmd/run.errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ export function classifySessionError(err: unknown): ClassifiedSessionError {
if (/heap out of memory|ENOMEM/i.test(message)) {
return { reason: "oom", message }
}
if (status && status >= 500 && status < 600) {
return { reason: "provider", code: String(status), message }
if ((name === "APIError" && status === undefined) || (status && status >= 500 && status < 600)) {
return { reason: "provider", code: status ? String(status) : undefined, message }
}
return { reason: "unknown", code: status ? String(status) : undefined, message }
}
Expand All @@ -50,7 +50,8 @@ function extractMessage(err: unknown): string {
function extractStatus(err: unknown): number | undefined {
if (err && typeof err === "object") {
const e = err as { status?: unknown; statusCode?: unknown; response?: { status?: unknown }; data?: unknown }
const data = e.data && typeof e.data === "object" ? (e.data as { status?: unknown; statusCode?: unknown }) : undefined
const data =
e.data && typeof e.data === "object" ? (e.data as { status?: unknown; statusCode?: unknown }) : undefined
const raw = e.status ?? e.statusCode ?? e.response?.status ?? data?.status ?? data?.statusCode
if (typeof raw === "number") return raw
if (typeof raw === "string" && /^\d+$/.test(raw)) return Number(raw)
Expand Down
31 changes: 28 additions & 3 deletions packages/cli/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {

Copy link
Copy Markdown

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
Fix this code review finding (aictrl-dev/cli PR #113, packages/cli/src/session/processor.ts:258):

Problem: Finish reasons other than error/content-filter still exit 0
Detail: 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.
Suggested fix: Verify how the provider SDK normalizes RECITATION/BLOCKLIST/OTHER; either map them into the failure set, or explicitly document the supported failure reasons in EVENTS.md and log unmapped ones. Extend tests with a RECITATION/OTHER case to pin the intended behavior.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
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.

                  // 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", {
                      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()
                  }

log.error("provider finish", {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 "other" finish abnormal terminations still exit 0.

Suggested change
log.error("provider finish", {
Verify the pinned @ai-sdk/google maps all Gemini failure finishReasons (PROHIBITED_CONTENT, LANGUAGE, FINISH_REASON_UNSPECIFIED) to "content-filter"/"error", and consider emitting a non-fatal warning event (or failing when the turn has no output) for "other" finishes so CI can distinguish abnormal termination from STOP.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #113, packages/cli/src/session/processor.ts:259-273):

Problem: "other" finish abnormal terminations still exit 0
Detail: Only "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.
Suggested fix: Verify the pinned @ai-sdk/google maps all Gemini failure finishReasons (PROHIBITED_CONTENT, LANGUAGE, FINISH_REASON_UNSPECIFIED) to "content-filter"/"error", and consider emitting a non-fatal warning event (or failing when the turn has no output) for "other" finishes so CI can distinguish abnormal termination from STOP.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

Only "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,
Expand All @@ -263,6 +281,13 @@ export namespace SessionProcessor {
cost: usage.cost,
})
await Session.updateMessage(input.assistantMessage)
if (input.assistantMessage.error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Error break may kill interactive sessions.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #113, packages/cli/src/session/processor.ts:284-290):

Problem: Error break may kill interactive sessions
Detail: On 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.
Suggested fix: Verify the interactive Session.Event.Error consumer and the catch block's recovery behavior; if interactive sessions should survive, gate the break on headless/invocation mode (or route through the same catch/failure handler a thrown nonretryable error uses). Add an interactive-mode test.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

On 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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Session.Event.Error publish awaited inconsistently.

Suggested change
sessionID: input.sessionID,
Await Bus.publish(Session.Event.Error, ...) at the max-retry branch (~L396) and the terminal catch branch (~L413) to match the new finish-step pattern, or extract a small awaited publishSessionError() helper used by all three sites.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #113, packages/cli/src/session/processor.ts:286-289):

Problem: Session.Event.Error publish awaited inconsistently
Detail: The 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.
Suggested fix: Await Bus.publish(Session.Event.Error, ...) at the max-retry branch (~L396) and the terminal catch branch (~L413) to match the new finish-step pattern, or extract a small awaited publishSessionError() helper used by all three sites.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The 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) {
Expand Down Expand Up @@ -349,7 +374,7 @@ export namespace SessionProcessor {
})
continue
}
if (needsCompaction) break
if (needsCompaction || input.assistantMessage.error) break
}
} catch (e: any) {
log.error("process", {
Expand All @@ -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,
})
Expand All @@ -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,
})
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/test/cli/classify-session-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,22 @@ describe("classifySessionError (#63)", () => {
expect(res.code).toBe("500")
expect(res.message).toBe("internal")
})

test.each([
[undefined, "The provider ended the response with an error finish reason.", "provider"],
[429, "Rate limit exceeded", "rate_limit"],
[429, "Resource has been exhausted", "rate_limit"],
[429, "rate_limit_error", "rate_limit"],
[401, "Invalid API key", "auth"],
[401, "Unauthenticated", "auth"],
[403, "Permission denied", "auth"],
[400, "Invalid request", "unknown"],
[402, "Payment required", "unknown"],
[404, "Not found", "unknown"],
[undefined, "Stream timeout", "timeout"],
] as const)("APIError preserves specific classifications (%s, %s) → %s", (statusCode, message, reason) => {
const res = classifySessionError({ name: "APIError", data: { statusCode, message, isRetryable: false } })
expect(res.reason).toBe(reason)
expect(res.code).toBe(statusCode ? String(statusCode) : undefined)
})
})
216 changes: 216 additions & 0 deletions packages/cli/test/cli/run-provider-finish.test.ts
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") } } },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetch handler references tmp before declaration.

Suggested change
{ functionCall: { name: "read", args: { filePath: path.join(tmp.path, "aictrl.json") } } },
Move the `await using tmp = await tmpdir({...})` setup above the Bun.serve call so the fetch handler only references already-initialized bindings.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #113, packages/cli/test/cli/run-provider-finish.test.ts:37):

Problem: fetch handler references tmp before declaration
Detail: The Bun.serve fetch handler references `tmp.path` inside the tool-call branch, but `await using tmp = await tmpdir(...)` is declared ~34 lines later, after the server is already listening. It only works because no request can arrive before tmp initializes (random port, CLI spawned afterwards), but the closure-over-later-const is a fragile use-before-declare pattern — any request arriving in the setup window would throw a TDZ ReferenceError inside the handler.
Suggested fix: Move the `await using tmp = await tmpdir({...})` setup above the Bun.serve call so the fetch handler only references already-initialized bindings.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The Bun.serve fetch handler references tmp.path inside the tool-call branch, but await using tmp = await tmpdir(...) is declared ~34 lines later, after the server is already listening. It only works because no request can arrive before tmp initializes (random port, CLI spawned afterwards), but the closure-over-later-const is a fragile use-before-declare pattern — any request arriving in the setup window would throw a TDZ ReferenceError inside the handler.

      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)
})
})
Loading