mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 00:26:28 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Native runner agents run on a provider (here Codex) with a model the
user saves on the agent
> - When the provider rejects that model, Paperclip should classify the
run as `native_provider_model_rejected`, ask the user to choose a model,
and let Retry finish the task
> - Paperclip recognized only the ChatGPT-account rejection (a 400 JSON
envelope). With API-key auth, Codex returns a different terminal error
for an unknown model
> - So the run failed as a generic `adapter_failed` ("Native session
failed"), and recovery opened a reconciliation incident ("Recovery
needed") for a turn that never did any work
> - This pull request recognizes the real API-key shape, bound to the
configured model, and names the rejected model on the task
> - The benefit is that the task asks for a model choice instead of a
reconciliation, and Retry completes the task after the user picks a
valid model
## Linked Issues or Issue Description
**What happened?**
The restart-chaos config fault CF3 `model_id` saves a Codex agent with
the model `chaos-missing-model-<nonce>`. The run stays "running" for
about 60 seconds, then fails with `errorCode: adapter_failed`, "Native
session failed". The task shows "Recovery needed", cause
`native_continuation_requires_reconciliation`. The grader fails as
`CF3/model_id:actionable_message` (waves `w-20261110T061914-s206-cf` t01
and `w-20261110T053831-s205-cf` t02).
Root cause: the wave runs Codex with an OpenAI API key. I drove the
bundled `codex app-server` (0.160.0) directly with an unknown model:
- With API-key auth, Codex retries the Responses API 404 ten times
(about 59 s), then fails the turn with `{"message":"unexpected status
404 Not Found: The model \`<id>\` does not exist or you do not have
access to it., url: https://api.openai.com/v1/responses, cf-ray: …,
request id:
req_…","codexErrorInfo":{"httpConnectionFailed":{"httpStatusCode":404}}}`.
- With ChatGPT auth, the turn fails in about 3 s with `message` =
`{"type":"error","status":400,"error":{"type":"invalid_request_error","message":"The
'<id>' model is not supported when using Codex with a ChatGPT
account.","code":"model_not_found"}}`. Paperclip already classified this
one.
`createNativeProviderFailureObservation` parsed only the JSON envelope,
and the runner's provider-failure category for an HTTP 404 is `unknown`.
The terminal therefore stayed unclassified.
**Expected behavior**
The run is `native_provider_model_rejected`, the task shows `userAction:
"choose_model"` and names the model, no reconciliation incident opens,
and Retry completes the task after the user picks a valid model.
**Steps to reproduce**
`node cli/node_modules/tsx/dist/cli.mjs
tests/restart-chaos/config-wave.ts --faults CF3 --adapters codex
--references model_id --tasks 1 --concurrency 1 --restarts K2 --seed
206` on `master` (with `OPENAI_API_KEY` set).
**Paperclip version or commit**
`master` at 5ab98b0.
## What Changed
- `server/src/services/native-runtime/native-provider-failure.ts`: the
Codex observation recognizes the API-key terminal shape
(`codexErrorInfo` `httpConnectionFailed` or `responseStreamDisconnected`
with `httpStatusCode: 404`, and the message prefix for the configured
model only). It classifies it as `native_provider_model_rejected` with a
new closed diagnostic `NATIVE_MODEL_NOT_FOUND_DIAGNOSTIC` (`codex`,
`model_not_found`, `404`, `api_key`). No provider text or model name is
stored in the diagnostic. A recoverable turn, another model's 404, or
another status is not classified.
- `readNativeModelRejectionDiagnostic` accepts both closed diagnostics,
so run failure diagnostics show it.
- `TaskInterruptionPresentation.rejectedModel` (shared type): with
`userAction: "choose_model"`, the task query carries the model id from
the run's native execution input. Only a
`native_provider_model_rejected` run discloses it.
- `TaskChatThread` names the model in the "Model unavailable" marker:
`The provider rejected the model "<id>".`
- The restart-chaos config wave reads `interruption.rejectedModel` as
task-level text. For `choose_model`, its repair step uses the Retry that
the "Model unavailable" marker offers (`POST /agents/{id}/wakeup` with
`reason: "retry_failed_run"`, the same request as
`agentsApi.retryFailedRun`), because a model rejection does not offer
the task-level Retry.
## Verification
- Red before, green after (the classification line disabled makes both
fail):
- `native-provider-failure.test.ts` — "classifies the API-key 404 for
the configured model as a model rejection (codex 0.160.0 shape)", with
the exact terminal error captured from `codex app-server` 0.160.0, plus
negative cases.
- `native-provider-failure.integration.test.ts` — new `api-key` scenario
through `executePaperclipNativeSession`, the persisted evidence reader
and `finalizeNativeRun`: the run ends `native_provider_model_rejected`,
the issue is `blocked` with that decision, and no issue recovery action
is created.
- `task-interruption-query.test.ts` — the real task query carries
`rejectedModel` only for a model rejection. `TaskChatThread.test.tsx` —
the marker names the model.
- `pnpm --filter @paperclipai/shared typecheck`, `pnpm --filter
@paperclipai/server typecheck`, `pnpm --filter @paperclipai/ui
typecheck` pass.
- Live proof: config wave `w-20261110T090801-s206-cf` (CF3 `model_id`,
codex with an OpenAI API key, K2, seed 206) on commit 05e8938: **1/1
pass**. The run ends `native_provider_model_rejected`; the task rests as
`waiting_for_user` / `choose_model` with `rejectedModel:
"chaos-missing-model-472e1851"` and execution cause
`native_provider_model_rejected` (no reconciliation incident), rests
again after the K2 restart, and finishes 27 s after the model is
restored and the marker's Retry is used. On `master` the same wave
failed as `actionable_message` with "Recovery needed" /
`native_continuation_requires_reconciliation`
(`w-20261110T061914-s206-cf`).
## Risks
- Low risk. The match is bound to the configured model, a 404 status and
a non-recoverable turn, so other provider failures keep their current
behavior.
- The match depends on the Codex terminal text. If a later Codex release
changes it, the run falls back to the old generic failure, not to a
wrong classification.
- Claude (ACPX) unknown models are not changed here. Claude Code reports
an unknown model as a synthetic assistant message ("There's an issue
with the selected model …") rather than a provider error; this PR does
not change or live-verify that path.
> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude
Code with tool use (shell, file edits, test runs, a direct `codex
app-server` probe).
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude <noreply@anthropic.com>
293 lines
20 KiB
TypeScript
293 lines
20 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { PrpEvent, PrpTerminalState } from "../../vendor/paperclip-runner/index.js";
|
|
import { createNativeProviderFailureObservation, NATIVE_MODEL_NOT_FOUND_DIAGNOSTIC, NATIVE_MODEL_REJECTION_DIAGNOSTIC, NATIVE_MODEL_REJECTION_MESSAGE, NATIVE_PROVIDER_OVERLOADED_MESSAGE, readNativeTurnFailureDiagnostic } from "./native-provider-failure.js";
|
|
|
|
const model = "test-model";
|
|
const response = {
|
|
type: "error", status: 400,
|
|
error: { type: "invalid_request_error", message: `The '${model}' model is not supported when using Codex with a ChatGPT account.` },
|
|
};
|
|
const event: PrpEvent = {
|
|
schema: "paperclip.prp.event.v1", runId: "run", normalizedSessionId: "session", turnId: "turn",
|
|
sourceInstanceId: "runner", sourceEventId: "failure", sourceSeq: 1, sourceKind: "runner",
|
|
eventType: "turn.failed", schemaVersion: 1, priority: 0, emittedAt: "2026-01-01T00:00:00.000Z",
|
|
payload: { status: "failed", error: { message: JSON.stringify(response), codexErrorInfo: "other" } },
|
|
};
|
|
const terminal: PrpTerminalState = {
|
|
schema: "paperclip.prp.terminal.v1", turnTerminalState: "failed", runTerminalState: "failed", reportedWorkDisposition: "blocked",
|
|
};
|
|
const observation = () => createNativeProviderFailureObservation({ kind: "codex", model });
|
|
|
|
describe("native provider failure observation", () => {
|
|
it.each(["acpx", "codex", "opencode"])("retains a typed explanation for %s without granting recovery authority", kind => {
|
|
const observed = createNativeProviderFailureObservation({ kind });
|
|
const providerFailure = { schema: "paperclip.provider-failure.v1", provider: "new-provider", phase: "turn",
|
|
category: "unknown", message: "An unfamiliar provider rejection.", retryability: "unknown" };
|
|
const failed = { ...event, payload: { status: "failed", error: { providerFailure } } };
|
|
observed.observe({ ...failed, sourceKind: "control_plane" });
|
|
expect(observed.providerFailureForTerminal("turn", terminal)).toBeNull();
|
|
observed.observe(failed);
|
|
expect(observed.providerFailureForTerminal("turn", terminal)).toEqual(providerFailure);
|
|
expect(observed.forTerminal("turn", terminal)).toBeNull();
|
|
expect(observed.providerFailureForTerminal("other-turn", terminal)).toBeNull();
|
|
expect(observed.providerFailureForTerminal("turn", { ...terminal, runTerminalState: "succeeded" })).toBeNull();
|
|
});
|
|
it.each(["opencode", "codex"])("classifies an action-required model_unavailable failure from %s as a model rejection", kind => {
|
|
const observed = createNativeProviderFailureObservation({ kind });
|
|
const providerFailure = { schema: "paperclip.provider-failure.v1", provider: "openrouter", phase: "turn",
|
|
category: "model_unavailable", message: "vendor/private-model is not a valid model ID", retryability: "requires_action", httpStatus: 400 };
|
|
observed.observe({ ...event, payload: { status: "failed", error: { providerFailure } } });
|
|
const result = observed.forTerminal("turn", terminal);
|
|
expect(result).toMatchObject({ errorCode: "native_provider_model_rejected", errorMessage: NATIVE_MODEL_REJECTION_MESSAGE });
|
|
expect(JSON.stringify(result)).not.toContain("private-model");
|
|
// A closed diagnostic only, and the classified turn is not also reported as unclassified.
|
|
expect(result?.diagnostic).toEqual(expect.objectContaining({ provider: kind }));
|
|
expect(JSON.stringify(result?.diagnostic)).not.toContain("not a valid model");
|
|
expect(observed.unclassifiedTurnFailure("turn", terminal)).toBeNull();
|
|
expect(observed.forTerminal("other-turn", terminal)).toBeNull();
|
|
expect(observed.forTerminal("turn", { ...terminal, runTerminalState: "succeeded" })).toBeNull();
|
|
});
|
|
it.each(["transient", "unknown"])("does not treat a %s model_unavailable failure as a model rejection", retryability => {
|
|
const observed = createNativeProviderFailureObservation({ kind: "opencode" });
|
|
observed.observe({ ...event, payload: { status: "failed", error: { providerFailure: { schema: "paperclip.provider-failure.v1", provider: "openrouter", phase: "turn", category: "model_unavailable", message: "busy", retryability } } } });
|
|
expect(observed.forTerminal("turn", terminal)).toBeNull();
|
|
});
|
|
it("classifies the API-key 404 for the configured model as a model rejection (codex 0.160.0 shape)", () => {
|
|
const apiKeyFailure = (message: string, info: unknown = { httpConnectionFailed: { httpStatusCode: 404 } }) => ({ ...event, payload: { status: "failed", error: {
|
|
message, codexErrorInfo: info, additionalDetails: null, misalignment: null } } });
|
|
const real = `unexpected status 404 Not Found: The model \`${model}\` does not exist or you do not have access to it., url: https://api.openai.com/v1/responses, cf-ray: a5833f01da2716ce-IAH, request id: req_584e320775c34b2c9c6d98af40e0ea3d`;
|
|
const observed = observation();
|
|
observed.observe(apiKeyFailure(real));
|
|
const result = observed.forTerminal("turn", terminal);
|
|
expect(result).toEqual({ errorCode: "native_provider_model_rejected", errorMessage: NATIVE_MODEL_REJECTION_MESSAGE, diagnostic: NATIVE_MODEL_NOT_FOUND_DIAGNOSTIC });
|
|
expect(JSON.stringify(result)).not.toContain(model);
|
|
expect(observed.unclassifiedTurnFailure("turn", terminal)).toBeNull();
|
|
expect(observed.forTerminal("other-turn", terminal)).toBeNull();
|
|
// Another model's 404, another status, or a retryable turn is not this rejection.
|
|
for (const [message, info, recoverable] of [
|
|
[real.replace(model, "other-model"), undefined, false],
|
|
[real, { httpConnectionFailed: { httpStatusCode: 500 } }, false],
|
|
[real, "other", false],
|
|
// Codex reports its retried 404s with this kind and willRetry; they never end the turn.
|
|
[real, { responseStreamDisconnected: { httpStatusCode: 404 } }, false],
|
|
[real, undefined, true],
|
|
] as const) {
|
|
const other = observation();
|
|
const failed = apiKeyFailure(message, info);
|
|
other.observe(recoverable ? { ...failed, payload: { ...failed.payload, recoverable: true } } : failed);
|
|
expect(other.forTerminal("turn", terminal)).toBeNull();
|
|
}
|
|
});
|
|
it("surfaces a structured capacity failure without copying arbitrary provider text", () => {
|
|
const observed = createNativeProviderFailureObservation({ kind: "codex" });
|
|
observed.observe({ ...event, payload: { status: "failed", error: { codexErrorInfo: "serverOverloaded", message: "Private arbitrary response" } } });
|
|
expect(observed.forTerminal("turn", terminal)).toEqual({ errorCode: "native_provider_overloaded", errorMessage: NATIVE_PROVIDER_OVERLOADED_MESSAGE, diagnostic: { provider: "codex", category: "server_overloaded" } });
|
|
expect(observed.forTerminal("different-turn", terminal)).toBeNull();
|
|
expect(observed.forTerminal("turn", { ...terminal, runTerminalState: "succeeded" })).toBeNull();
|
|
});
|
|
|
|
it.each(["usageLimitExceeded", "other", "unauthorized", undefined])("does not retry unrelated provider errors (%s)", (codexErrorInfo) => {
|
|
const observed = observation();
|
|
observed.observe({ ...event, payload: { status: "failed", error: { codexErrorInfo, message: NATIVE_PROVIDER_OVERLOADED_MESSAGE } } });
|
|
expect(observed.forTerminal("turn", terminal)).toBeNull();
|
|
});
|
|
it("binds the structured rejection to the failed turn and emits no response or model text", () => {
|
|
const observed = observation();
|
|
observed.observe(event);
|
|
observed.observe(event); // A byte-identical committed replay remains idempotent.
|
|
const result = observed.forTerminal("turn", terminal);
|
|
expect(result).toEqual({ errorCode: "native_provider_model_rejected", errorMessage: NATIVE_MODEL_REJECTION_MESSAGE, diagnostic: NATIVE_MODEL_REJECTION_DIAGNOSTIC });
|
|
expect(JSON.stringify(result)).not.toContain(model);
|
|
expect(JSON.stringify(result)).not.toContain("invalid_request_error");
|
|
expect(observed.forTerminal("later-turn", terminal)).toBeNull();
|
|
expect(observed.forTerminal(null, terminal)).toBeNull();
|
|
expect(observed.forTerminal("turn", { ...terminal, runTerminalState: "succeeded" })).toBeNull();
|
|
expect(observed.forTerminal("turn", { ...terminal, turnTerminalState: "cancelled" })).toBeNull();
|
|
});
|
|
|
|
it.each([
|
|
{ ...event, sourceKind: "control_plane" as const },
|
|
{ ...event, eventType: "item.completed" as const },
|
|
{ ...event, eventType: "harness.diagnostic" as const },
|
|
{ ...event, turnId: undefined },
|
|
{ ...event, payload: { ...event.payload, status: "completed" } },
|
|
{ ...event, payload: { ...event.payload, recoverable: true } },
|
|
{ ...event, payload: { status: "failed", error: { message: JSON.stringify(response), recoverable: true } } },
|
|
])("ignores non-authoritative or retryable evidence %#", (unrelated) => {
|
|
const observed = observation();
|
|
observed.observe(unrelated);
|
|
expect(observed.forTerminal("turn", terminal)).toBeNull();
|
|
});
|
|
|
|
it.each([
|
|
response.error.message, "malformed JSON", " ".repeat(4_097),
|
|
JSON.stringify({ ...response, status: 401 }),
|
|
JSON.stringify({ ...response, status: "400" }),
|
|
JSON.stringify({ ...response, type: "notice" }),
|
|
JSON.stringify({ ...response, error: { ...response.error, type: "server_error" } }),
|
|
JSON.stringify({ ...response, error: { ...response.error, message: "Private arbitrary provider text" } }),
|
|
JSON.stringify({ ...response, error: { ...response.error, message: response.error.message + " Private suffix" } }),
|
|
JSON.stringify({ ...response, error: { ...response.error, message: response.error.message.replace(model, "another-model") } }),
|
|
JSON.stringify([response]), "null",
|
|
])("leaves unknown failures unchanged %#", (message) => {
|
|
const observed = observation();
|
|
observed.observe({ ...event, payload: { status: "failed", error: { message } } });
|
|
expect(observed.forTerminal("turn", terminal)).toBeNull();
|
|
});
|
|
|
|
it.each([
|
|
[{ codexErrorInfo: "contextWindowExceeded", message: "Private provider text" }, { codexErrorInfo: "contextWindowExceeded" }],
|
|
[{ codexErrorInfo: { responseStreamDisconnected: { httpStatusCode: 502 } }, message: "Private" }, { codexErrorInfo: "responseStreamDisconnected", httpStatus: 502 }],
|
|
[{ codexErrorInfo: { httpConnectionFailed: { httpStatusCode: "502" } } }, { codexErrorInfo: "httpConnectionFailed" }],
|
|
[{ codexErrorInfo: { httpConnectionFailed: { httpStatusCode: 9_999 } } }, { codexErrorInfo: "httpConnectionFailed" }],
|
|
[{ codexErrorInfo: "privateUnknownKind" }, { codexErrorInfo: "unrecognized" }],
|
|
[{ codexErrorInfo: { privateUnknownKind: {} } }, { codexErrorInfo: "unrecognized" }],
|
|
[{ codexErrorInfo: { other: {}, badRequest: {} } }, { codexErrorInfo: "unrecognized" }],
|
|
[{ code: "provider_event_type_invalid", message: "Unknown canonical provider event type.", recoverable: false }, { codexErrorInfo: "absent", runnerFailureCode: "provider_event_type_invalid" }],
|
|
[{ code: "Private Code With Spaces", message: "Private" }, { codexErrorInfo: "absent", runnerFailureCode: "unrecognized" }],
|
|
[{ code: "private_prompt_slug", message: "Private" }, { codexErrorInfo: "absent", runnerFailureCode: "unrecognized" }],
|
|
[{ code: 42, message: "Private" }, { codexErrorInfo: "absent" }],
|
|
[{ message: NATIVE_PROVIDER_OVERLOADED_MESSAGE }, { codexErrorInfo: "absent" }],
|
|
])("describes an unclassified failed turn with closed vocabulary only %#", (error, expected) => {
|
|
const observed = observation();
|
|
observed.observe({ ...event, payload: { status: "failed", error } });
|
|
expect(observed.forTerminal("turn", terminal)).toBeNull();
|
|
const diagnostic = observed.unclassifiedTurnFailure("turn", terminal);
|
|
expect(diagnostic).toEqual({ provider: "codex", category: "turn_failed", ...expected });
|
|
expect(JSON.stringify(diagnostic)).not.toMatch(/private/i);
|
|
expect(readNativeTurnFailureDiagnostic(diagnostic)).toEqual(diagnostic);
|
|
});
|
|
|
|
it("does not describe a classified, unfailed, or unrelated turn as unclassified", () => {
|
|
const rejected = observation();
|
|
rejected.observe(event);
|
|
expect(rejected.unclassifiedTurnFailure("turn", terminal)).toBeNull();
|
|
const overloaded = observation();
|
|
overloaded.observe({ ...event, payload: { status: "failed", error: { codexErrorInfo: "serverOverloaded" } } });
|
|
expect(overloaded.unclassifiedTurnFailure("turn", terminal)).toBeNull();
|
|
const other = observation();
|
|
other.observe({ ...event, payload: { status: "failed", error: { codexErrorInfo: "sandboxError" } } });
|
|
expect(other.unclassifiedTurnFailure("later-turn", terminal)).toBeNull();
|
|
expect(other.unclassifiedTurnFailure(null, terminal)).toBeNull();
|
|
expect(other.unclassifiedTurnFailure("turn", { ...terminal, runTerminalState: "succeeded" })).toBeNull();
|
|
expect(other.unclassifiedTurnFailure("turn", { ...terminal, turnTerminalState: "cancelled" })).toBeNull();
|
|
const claude = createNativeProviderFailureObservation({ kind: "claude", model });
|
|
claude.observe({ ...event, payload: { status: "failed", error: { codexErrorInfo: "sandboxError" } } });
|
|
expect(claude.unclassifiedTurnFailure("turn", terminal)).toBeNull();
|
|
});
|
|
|
|
it.each([
|
|
null, [], "turn_failed",
|
|
{ provider: "codex", category: "turn_failed", codexErrorInfo: "private-kind" },
|
|
{ provider: "claude", category: "turn_failed", codexErrorInfo: "other" },
|
|
{ provider: "codex", category: "server_overloaded", codexErrorInfo: "other" },
|
|
])("rejects a persisted turn-failure diagnostic outside the vocabulary %#", (value) => {
|
|
expect(readNativeTurnFailureDiagnostic(value)).toBeNull();
|
|
});
|
|
|
|
it("drops unbounded fields from a persisted turn-failure diagnostic", () => {
|
|
expect(readNativeTurnFailureDiagnostic({
|
|
provider: "codex", category: "turn_failed", codexErrorInfo: "badRequest", httpStatus: 1.5,
|
|
runnerFailureCode: "private_prompt_slug", message: "private provider response",
|
|
})).toEqual({ provider: "codex", category: "turn_failed", codexErrorInfo: "badRequest", runnerFailureCode: "unrecognized" });
|
|
});
|
|
|
|
it("names a lost OpenCode server without copying provider text", () => {
|
|
const observed = createNativeProviderFailureObservation({ kind: "opencode", model });
|
|
observed.observe({ ...event, payload: { status: "failed", providerTerminalObserved: false, error: {
|
|
code: "opencode_server_exited", message: "Private provider message",
|
|
opencodeFailure: { kind: "server_exited", signal: "SIGKILL", connectionError: "ECONNREFUSED", detail: "private" },
|
|
} } });
|
|
expect(observed.forTerminal("turn", terminal)).toBeNull();
|
|
const diagnostic = observed.unclassifiedTurnFailure("turn", terminal);
|
|
expect(diagnostic).toEqual({
|
|
provider: "opencode", category: "turn_failed", runnerFailureCode: "opencode_server_exited",
|
|
streamFailure: "server_exited", signal: "SIGKILL", connectionError: "ECONNREFUSED",
|
|
});
|
|
expect(JSON.stringify(diagnostic)).not.toMatch(/private/i);
|
|
expect(readNativeTurnFailureDiagnostic(diagnostic)).toEqual(diagnostic);
|
|
});
|
|
|
|
it("still identifies an OpenCode turn that failed with no structured cause", () => {
|
|
const observed = createNativeProviderFailureObservation({ kind: "opencode", model });
|
|
observed.observe({ ...event, payload: { status: "failed", error: null } });
|
|
expect(observed.unclassifiedTurnFailure("turn", terminal)).toEqual({ provider: "opencode", category: "turn_failed" });
|
|
const providerError = createNativeProviderFailureObservation({ kind: "opencode", model });
|
|
providerError.observe({ ...event, payload: { status: "failed", error: { code: "private_provider_code", name: "APIError" } } });
|
|
expect(providerError.unclassifiedTurnFailure("turn", terminal)).toEqual({
|
|
provider: "opencode", category: "turn_failed", runnerFailureCode: "unrecognized",
|
|
});
|
|
});
|
|
|
|
it("drops values outside the OpenCode stream-failure vocabulary", () => {
|
|
expect(readNativeTurnFailureDiagnostic({
|
|
provider: "opencode", category: "turn_failed", runnerFailureCode: "opencode_event_stream_lost",
|
|
streamFailure: "connection_failed", connectionError: "EPRIVATE", exitCode: 300, signal: "private signal", httpStatus: 42,
|
|
})).toEqual({
|
|
provider: "opencode", category: "turn_failed", runnerFailureCode: "opencode_event_stream_lost", streamFailure: "connection_failed",
|
|
});
|
|
expect(readNativeTurnFailureDiagnostic({
|
|
provider: "opencode", category: "turn_failed", streamFailure: "private", exitCode: 137,
|
|
})).toEqual({ provider: "opencode", category: "turn_failed" });
|
|
expect(readNativeTurnFailureDiagnostic({ provider: "opencode", category: "server_overloaded" })).toBeNull();
|
|
});
|
|
|
|
it("records only known signal names for a lost OpenCode server", () => {
|
|
const observed = createNativeProviderFailureObservation({ kind: "opencode", model });
|
|
observed.observe({ ...event, payload: { status: "failed", error: {
|
|
code: "opencode_server_exited", opencodeFailure: { kind: "server_exited", signal: "SIGPRIVATE" },
|
|
} } });
|
|
const expected = { provider: "opencode", category: "turn_failed", runnerFailureCode: "opencode_server_exited", streamFailure: "server_exited" };
|
|
expect(observed.unclassifiedTurnFailure("turn", terminal)).toEqual(expected);
|
|
expect(readNativeTurnFailureDiagnostic({ ...expected, signal: "SIGPRIVATE" })).toEqual(expected);
|
|
expect(readNativeTurnFailureDiagnostic({ ...expected, signal: "SIGTERM" })).toEqual({ ...expected, signal: "SIGTERM" });
|
|
});
|
|
|
|
it("records an OpenCode upstream provider failure's closed fields without its message or code", () => {
|
|
const observed = createNativeProviderFailureObservation({ kind: "opencode", model });
|
|
const providerFailure = { schema: "paperclip.provider-failure.v1", provider: "openrouter", phase: "turn",
|
|
category: "unknown", message: "Private upstream explanation", providerCode: "private_provider_type",
|
|
httpStatus: 502, retryability: "unknown" };
|
|
observed.observe({ ...event, payload: { status: "failed", error: {
|
|
name: "UnknownError", data: { message: "Private upstream body" }, providerFailure,
|
|
} } });
|
|
expect(observed.forTerminal("turn", terminal)).toBeNull();
|
|
expect(observed.providerFailureForTerminal("turn", terminal)).toEqual(providerFailure);
|
|
const diagnostic = observed.unclassifiedTurnFailure("turn", terminal);
|
|
expect(diagnostic).toEqual({
|
|
provider: "opencode", category: "turn_failed",
|
|
providerFailureCategory: "unknown", providerFailureRetryability: "unknown", httpStatus: 502,
|
|
});
|
|
expect(JSON.stringify(diagnostic)).not.toMatch(/private/i);
|
|
expect(readNativeTurnFailureDiagnostic(diagnostic)).toEqual(diagnostic);
|
|
// A malformed provider failure adds nothing, and a persisted record keeps only known values.
|
|
const malformed = createNativeProviderFailureObservation({ kind: "opencode", model });
|
|
malformed.observe({ ...event, payload: { status: "failed", error: { providerFailure: { ...providerFailure, category: "private" } } } });
|
|
expect(malformed.unclassifiedTurnFailure("turn", terminal)).toEqual({ provider: "opencode", category: "turn_failed" });
|
|
expect(readNativeTurnFailureDiagnostic({ provider: "opencode", category: "turn_failed",
|
|
providerFailureCategory: "private", providerFailureRetryability: "unknown", httpStatus: 502 }))
|
|
.toEqual({ provider: "opencode", category: "turn_failed" });
|
|
expect(readNativeTurnFailureDiagnostic({ provider: "opencode", category: "turn_failed",
|
|
providerFailureCategory: "overloaded", providerFailureRetryability: "private", httpStatus: 42 }))
|
|
.toEqual({ provider: "opencode", category: "turn_failed", providerFailureCategory: "overloaded" });
|
|
});
|
|
|
|
it("names the OpenCode empty-response failure the driver writes", () => {
|
|
const observed = createNativeProviderFailureObservation({ kind: "opencode", model });
|
|
observed.observe({ ...event, payload: { status: "failed", error: {
|
|
code: "opencode_empty_response", message: "OpenCode ended the turn without a final response.", retryable: false,
|
|
} } });
|
|
const diagnostic = observed.unclassifiedTurnFailure("turn", terminal);
|
|
expect(diagnostic).toEqual({ provider: "opencode", category: "turn_failed", runnerFailureCode: "opencode_empty_response" });
|
|
expect(readNativeTurnFailureDiagnostic(diagnostic)).toEqual(diagnostic);
|
|
});
|
|
|
|
it.each([{ kind: "claude", model }, { kind: "codex" }, { kind: "codex", model: "other" }])(
|
|
"does not apply a rejection to another selected provider/model (%j)", (provider) => {
|
|
const observed = createNativeProviderFailureObservation(provider);
|
|
observed.observe(event);
|
|
expect(observed.forTerminal("turn", terminal)).toBeNull();
|
|
},
|
|
);
|
|
});
|