Compare commits

...

16 Commits

Author SHA1 Message Date
Chris Farhood 570fdae9c4 fix(models): restore static model list matching opencode_local pattern
Adds back STATIC_MODELS with correct provider/model IDs and consistent
labels (matching listModels output format) so the UI is not blank before
listModels resolves. Restores server-adapter test with a contract check
that enforces provider/model label consistency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 07:18:46 -04:00
Chris Farhood 985d55e125 fix(cancel-poll): use ctx.authToken instead of process.env for cancel polling
The cancel poll was sending empty Authorization headers because
PAPERCLIP_API_KEY is not set on the Paperclip server pod. Use the
per-run authToken from ctx instead, which is the JWT issued by Paperclip
for this execution. PAPERCLIP_DEV_API_KEY still overrides for dev instances.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 07:11:47 -04:00
Chris Farhood 5e67a4dd3b docs: note tag-based publish workflow in CLAUDE.md 2026-04-26 21:59:17 -04:00
Chris Farhood 5f75c2b81b feat(models): port model discovery to match opencode_local adapter
Replace static hardcoded fallback list with the same robust approach used
by the opencode_local adapter: runChildProcess + ensurePathInEnv, HOME fix
via os.userInfo(), 60s TTL cache, returns [] on failure instead of a stale
list. Also updates CLAUDE.md and README.md with missing fields/features.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:51:26 -04:00
Chris Farhood 7043e71ff6 fix(models): expose static models list so UI renders entries before listModels resolves
The UI was showing 0 models because the adapter only set listModels (async)
without a static models array. The static array is what the UI reads
synchronously to populate the selector — listModels is for refresh.

- Move the fallback list out as STATIC_MODELS (expanded to cover Opus/Sonnet/Haiku 4.x, GPT-4o/5, Gemini 2.5, Grok 4, DeepSeek)
- Set models: STATIC_MODELS on the adapter module
- Keep listK8sModels for runtime refresh from `opencode models` (with STATIC_MODELS fallback on error or empty stdout)
- Add server-adapter.test.ts asserting models is non-empty
- Add models.test.ts coverage for the empty-stdout fallback path

chore: bump version to 0.1.33

Fixes FAR-94.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-26 02:59:05 +00:00
Chris Farhood da1b55d233 test: add coverage for listK8sModels CLI fetch and fallback
chore: bump version to 0.1.32

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-26 02:07:51 +00:00
Chris Farhood 168161148c feat: fetch model list from opencode CLI instead of hardcoded static list
Reads available models dynamically via 'opencode models' command
to populate the Paperclip UI model selector. Falls back to previous
static list on any error (missing CLI, timeout, parse failure).

Fixes FAR-94: 0 models shown in adapter UI.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-26 01:59:35 +00:00
Chris Farhood 2daedda537 Surface volume mount and PVC bind errors in waitForPod()
Handle MountVolumeFailed/ContainerCannotMount waiting reasons in pod
container status checks, throwing clear errors instead of silent failure.

Detect PVC/volume/bind/mount keywords in PodScheduled condition messages
and surface as 'PVC bind failed' error.

Fixes FAR-93: serviceAccountName missing error surfacing.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-26 01:47:31 +00:00
Chris Farhood e364e09113 test: assert config.image overrides selfPod image in job manifest
Locks in the existing override behavior so a future regression that
reverts to a hardcoded image is caught immediately. Closes the
investigation on FAR-90.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-26 01:12:05 +00:00
Chris Farhood 4c956cc039 chore: bump version to 0.1.31 2026-04-26 00:25:06 +00:00
Chris Farhood 4fcd3b4547 fix test: stub PAPERCLIP_DEV_API_KEY before each cancel-poll test
The cancel-poll test sets PAPERCLIP_API_KEY='test-key' but the actual
PAPERCLIP_DEV_API_KEY was leaking through from the harness environment.
Since execute.ts prefers PAPERCLIP_DEV_API_KEY over PAPERCLIP_API_KEY,
the poll was sending the real dev key instead of 'test-key'.

Fix: add beforeEach to set PAPERCLIP_DEV_API_KEY='test-key', and afterEach
to clean both env vars.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-25 23:40:41 +00:00
Chris Farhood 1bad618b29 chore: bump version to 0.1.30
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-25 23:25:17 +00:00
Chris Farhood 5670da320a Fix OPENCODE_DB to be a file path inside /opencode-db mount
The env var was set to /opencode-db (the mount point directory), but sqlite
requires a file path. Changed to /opencode-db/opencode.db.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-25 23:23:59 +00:00
Chris Farhood 798b80f2f2 test: push coverage to 90%+ on lines for all files except execute.ts (FAR-85)
Overall before: 80.36% lines / 79.06% statements
Overall after:  94.65% lines / 93.30% statements

Per-file lines coverage (all targets ≥90% except execute.ts):

| File              | Before | After  |
|-------------------|--------|--------|
| ui-parser.ts      | 93.63% | 99.09% |
| cli/format-event  | 59.85% | 99.27% |
| server/execute    | 81.47% | 89.64% |
| server/job-mfst   | 90.30% | 98.78% |
| server/k8s-client | 37.50% | 95.83% |
| server/log-dedup  | 97.77% | 97.77% |
| server/parse      | 89.85% | 98.55% |
| server/skills     | 100%   | 100%   |

New tests added:

- k8s-client.test.ts: getSelfPodInfo (env-var inheritance, secret volumes,
  PVC discovery, dnsConfig, all error paths) + kubeconfig file branch
- format-event.test.ts: parseStdoutLine (cli) — full event-type matrix,
  tool_use status branches, errorText fallback paths
- ui-parser.test.ts: errorText edge cases, empty event paths
- parse.test.ts: errorText fallback to data.message, name, code, JSON
- job-manifest.test.ts: workspace context env wiring, linkedIssueIds,
  paperclipWorkspaces/RuntimeServices JSON, authToken, inherited URLs,
  prompt-secret + data PVC + secret-volume mount paths
- execute.test.ts: parseModelProvider, completionWithGrace,
  instructionsFilePath read failure, ensureAgentDbPvc throw paths,
  large-prompt secret create failure, step-limit detection,
  waitForPod no-pod messaging, init-container ImagePullBackOff /
  CrashLoopBackOff, main-container CrashLoopBackOff, all-inits-done
  happy path, skill bundle source loading (SKILL.md + flat-file
  fallback), SIGTERM handler full body via vi.resetModules()

execute.ts remains at 89.64% lines — the residual gap is deep async/timer
paths inside streamAndAwaitJob (grace poller, keepalive ticker, log-stream
stop-signal/bail timer). Those need fake-timer scaffolding heavier than
this batch warrants; tracking separately.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-25 22:27:04 +00:00
Chris Farhood 693016d1ab chore: add @vitest/coverage-v8 to enable coverage reports
So we can answer "what's coverage?" without re-installing each time.
Run with: \`npx vitest run --coverage --coverage.provider=v8 --coverage.reporter=text-summary\`

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-25 22:09:55 +00:00
Chris Farhood c71e224b43 test: cover k8s-client 404 detection against real ApiException shape (FAR-85)
There was no test file for k8s-client.ts. Existing pvc.test.ts mocked
`getPvc` directly and never exercised the underlying isNotFound predicate,
so the v1.x ApiException `code` vs `statusCode` regression had nothing to
catch it.

Add k8s-client.test.ts that mocks @kubernetes/client-node, throws errors
shaped exactly like the real ApiException (status under `code`), and
verifies:

- getPvc returns null on code=404 (the FAR-85 case)
- getPvc still handles legacy statusCode=404 and response.statusCode=404
- getPvc re-throws non-404 errors (500, 403)
- deletePvc swallows 404, re-throws others
- createPvc forwards spec to the SDK

Confirmed the new tests fail when k8s-client.ts is reverted to the
pre-fix predicate (2 failures), and pass with the fix in place.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-25 22:05:48 +00:00
17 changed files with 2077 additions and 204 deletions
+22 -9
View File
@@ -13,6 +13,16 @@ npm run test:watch # Run vitest in watch mode
Run a single test file: `npx vitest run src/server/parse.test.ts`
## Publishing
Bump `version` in `package.json`, commit, push to `master`, then push a matching tag — the CI publish job only runs on `v*` tags:
```bash
git tag v0.1.x && git push origin v0.1.x
```
The workflow verifies the tag matches `package.json` version before publishing to npm.
## Architecture
This is a Paperclip adapter plugin that runs OpenCode agents as isolated Kubernetes Job pods. It exposes three entry points:
@@ -20,23 +30,24 @@ This is a Paperclip adapter plugin that runs OpenCode agents as isolated Kuberne
- `.``src/index.ts` — main ServerAdapterModule factory
- `./server``src/server/index.ts` — server adapter internals
- `./ui-parser``src/ui-parser.ts` — real-time stdout line parser for the Paperclip UI
- `./cli``src/cli/index.ts` — CLI adapter module with console output formatter
### Execution Flow (`src/server/execute.ts`)
1. **Concurrency guard** — checks for existing running Jobs for the same agent (shared PVC/session enforcement)
2. **Self-pod introspection** (`getSelfPodInfo`) — queries own pod to inherit image, imagePullSecrets, DNS config, PVC mount, and all env vars from the Deployment
3. **Instructions + skill bundle resolution** — reads `instructionsFilePath` from config and desired skill markdown files from the PVC; content is prepended to the prompt at build time
4. **Job manifest build** (`buildJobManifest`) — constructs a K8s Job with:
- Init container (busybox) that writes the prompt to an emptyDir volume
- Main opencode container that pipes the prompt via stdin
4. **Agent DB PVC management** — if `agentDbMode: dedicated_pvc`, ensures a per-agent RWX PVC named `opencode-db-{agentId}` exists (creates it if missing), then mounts it at `/opencode-db` with `OPENCODE_DB=/opencode-db`; defaults to ephemeral emptyDir
5. **Job manifest build** (`buildJobManifest`) — constructs a K8s Job with:
- Prompt delivery: small prompts (< 256 KiB) via env var; large prompts via K8s Secret + busybox init container that copies to emptyDir, then piped via stdin
- Prompt assembled as: `[instructionsContent] + [skillsBundleContent] + bootstrapPrompt + wakePrompt + sessionHandoff + heartbeatPrompt`
- Inherited env vars layered: Deployment env → PAPERCLIP_* vars → user overrides
- Inherited env vars layered: Deployment env → PAPERCLIP_* vars → user overrides; always sets `HOME=/paperclip` and `OPENCODE_DISABLE_PROJECT_CONFIG=true`
- Resource requests/limits, security contexts, tolerations, nodeSelector applied from config
5. **Job creation** — creates the Job in the target namespace
6. **Pod scheduling wait** — polls for the pod to be scheduled, checking init container states and image pull issues
7. **Log streaming + completion wait** — streams pod logs to the Paperclip UI while waiting for Job completion (with configurable timeout)
8. **JSONL parsing** (`parseOpenCodeJsonl`) — extracts session ID, usage tokens, cost, summary, and errors from OpenCode JSONL output
9. **Result synthesis** — returns exit code, usage metrics, session params for resume, and billing type inference
6. **Job creation** — creates the Job in the target namespace
7. **Pod scheduling wait** — polls for the pod to be scheduled, checking init container states and image pull issues
8. **Log streaming + completion wait** — streams pod logs with automatic reconnect on K8s API drops; `LogLineDedupFilter` (`log-dedup.ts`) deduplicates replayed lines on reconnect using structural keys (`type:sessionID:partId` for JSONL events, `raw:{content}` for plain lines)
9. **JSONL parsing** (`parseOpenCodeJsonl`) — extracts session ID, usage tokens, cost, summary, and errors from OpenCode JSONL output
10. **Result synthesis** — returns exit code, usage metrics, session params for resume, and billing type inference
### Skill Materialization (`src/server/skills.ts` + `src/server/execute.ts`)
@@ -84,5 +95,7 @@ src/
session.ts — sessionCodec (serialize/deserialize session params)
config-schema.ts — getConfigSchema() (adapter UI config fields)
test.ts — testEnvironment() (K8s environment health checks)
models.ts — static model list + dynamic fetch from `opencode models` CLI (with fallback)
log-dedup.ts — LogLineDedupFilter for reconnect replay deduplication
*.test.ts — vitest unit tests
```
+40 -13
View File
@@ -6,10 +6,12 @@ Paperclip adapter plugin that runs OpenCode agents as isolated Kubernetes Jobs i
- Spawns agent runs as K8s Jobs with full pod isolation
- Inherits container image, secrets, DNS, and PVC from the Paperclip Deployment automatically
- Real-time log streaming from Job pods back to the Paperclip UI
- Real-time log streaming from Job pods back to the Paperclip UI with automatic reconnect and replay deduplication
- Session resume via shared RWX PVC
- Per-agent concurrency guard
- Configurable resources, namespace, kubeconfig
- Skills bundle injection — skill markdown content prepended to each run prompt at execution time
- Optional per-agent database PVC (`agentDbMode: dedicated_pvc`) for persistent agent state across runs
- Configurable resources, namespace, kubeconfig, node selectors, and tolerations
- Runtime config injection for permission bypass
## Prerequisites
@@ -96,15 +98,15 @@ rules:
resources: ["namespaces"]
verbs: ["get"]
# Verify the RWX PVC exists and has the correct access mode
# Verify the RWX PVC; create/delete per-agent DB PVCs (agentDbMode: dedicated_pvc)
- apiGroups: [""]
resources: ["persistentvolumeclaims"]
verbs: ["get"]
verbs: ["get", "create", "delete"]
# Verify optional secrets exist (e.g. paperclip-secrets)
# Verify optional secrets; create/delete prompt-delivery Secrets for large prompts (> 256 KiB)
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get"]
verbs: ["create", "delete", "get"]
# RBAC self-test during adapter validation
- apiGroups: ["authorization.k8s.io"]
@@ -172,17 +174,40 @@ curl -X POST http://localhost:3100/api/adapters \
Agent-level configuration fields:
**Core**
| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `model` | **yes** | — | OpenCode model in `provider/model` format |
| `variant` | no | — | Reasoning profile variant |
| `instructionsFilePath` | no | — | Absolute path to a markdown file prepended to every run prompt (e.g. `/paperclip/.claude/projects/COMPANY/agents/AGENT/AGENTS.md`) |
| `dangerouslySkipPermissions` | no | `true` | Inject runtime config granting `permission.external_directory=allow` |
| `agentDbMode` | no | `ephemeral` | `ephemeral` (emptyDir, lost on exit) or `dedicated_pvc` (per-agent RWX PVC at `/opencode-db`) |
| `agentDbStorageClass` | no | Cluster default | StorageClass for dedicated agent DB PVC |
| `agentDbStorageCapacity` | no | `10Gi` | Storage size for dedicated agent DB PVC |
**Kubernetes**
| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `namespace` | no | Deployment namespace | K8s namespace for agent Jobs |
| `image` | no | Deployment image | Override container image for Jobs |
| `imagePullPolicy` | no | — | Image pull policy for Job pods |
| `kubeconfig` | no | In-cluster | Path to kubeconfig file |
| `serviceAccountName` | no | Default SA | Service account for Job pods |
| `resources` | no | See below | CPU/memory requests and limits |
| `nodeSelector` | no | — | Node selector key=value pairs (one per line) |
| `tolerations` | no | — | Pod tolerations in YAML format |
| `ttlSecondsAfterFinished` | no | `300` | Seconds before completed Jobs are auto-deleted |
| `retainJobs` | no | `false` | Keep completed Jobs for debugging (disables TTL) |
| `reattachOrphanedJobs` | no | `false` | Resume streaming if a matching Job is already running after adapter restart |
**Operational**
| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `timeoutSec` | no | `0` (none) | Run timeout in seconds |
| `retainJobs` | no | `false` | Keep completed Jobs for debugging |
| `graceSec` | no | `30` | Grace period after timeout before forceful termination |
| `env` | no | — | Additional environment variables for Jobs |
### Default Resource Requests and Limits
@@ -201,11 +226,13 @@ resources:
## How It Works
1. **Self-introspection** — on first run, the adapter reads the Paperclip Deployment pod's own spec to discover the PVC claim name, mounted secrets, image pull secrets, DNS config, and environment variables.
2. **Job creation** — each agent run creates a Kubernetes Job in the target namespace. The Job pod mounts the same RWX PVC at `/paperclip`, inherits all secrets and env vars, and runs the agent command.
3. **Log streaming** — the adapter streams stdout/stderr from the Job pod back to the Paperclip UI in real time.
4. **Concurrency guard** — only one Job per agent is allowed at a time (enforced via label selectors).
5. **Cleanup** — completed Jobs are automatically deleted after 300 seconds (`ttlSecondsAfterFinished`), or retained if `retainJobs` is enabled.
1. **Self-introspection** — on first run, the adapter reads the Paperclip Deployment pod's own spec to discover the PVC claim name, mounted secrets, image pull secrets, DNS config, and environment variables. This is cached for all subsequent runs in the same process.
2. **Concurrency guard** — only one Job per agent is allowed at a time, enforced via K8s label selectors before Job creation.
3. **Prompt assembly** — instructions file, skills markdown bundle, bootstrap prompt, session handoff, and heartbeat are concatenated in order. Prompts under 256 KiB are delivered via environment variable; larger prompts are written to a K8s Secret and copied into the pod by a busybox init container.
4. **Agent DB PVC** — if `agentDbMode: dedicated_pvc`, a per-agent RWX PVC named `opencode-db-{agentId}` is created if it does not exist, then mounted at `/opencode-db` with `OPENCODE_DB=/opencode-db`.
5. **Job creation** — a Kubernetes Job is created in the target namespace. The Job pod mounts the shared RWX PVC at `/paperclip`, inherits all secrets and env vars, and runs the OpenCode agent.
6. **Log streaming** — the adapter streams stdout/stderr from the Job pod back to the Paperclip UI in real time, with automatic reconnect on K8s API drops and replay deduplication to avoid duplicate output.
7. **Cleanup** — completed Jobs are automatically deleted after `ttlSecondsAfterFinished` seconds (default 300), or retained if `retainJobs` is enabled.
### Security Context
@@ -220,7 +247,7 @@ All Job pods run with a locked-down security context:
## Dependencies
- `@kubernetes/client-node` ^1.0.0
- `@paperclipai/adapter-utils` >=2026.411.0-canary.8 (peer dependency)
- `@paperclipai/adapter-utils` >=2026.415.0-canary.7 (peer dependency)
## License
+381 -139
View File
@@ -1,12 +1,12 @@
{
"name": "paperclip-adapter-opencode-k8s",
"version": "0.1.26",
"version": "0.1.30",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paperclip-adapter-opencode-k8s",
"version": "0.1.26",
"version": "0.1.30",
"license": "MIT",
"dependencies": {
"@kubernetes/client-node": "^1.0.0",
@@ -15,6 +15,7 @@
"devDependencies": {
"@paperclipai/adapter-utils": "2026.415.0-canary.7",
"@types/node": "^24.6.0",
"@vitest/coverage-v8": "^4.1.5",
"typescript": "^5.7.3",
"vitest": "^4.1.4"
},
@@ -22,10 +23,70 @@
"@paperclipai/adapter-utils": ">=2026.415.0-canary.7"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.0"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@emnapi/core": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -35,9 +96,9 @@
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -56,6 +117,16 @@
"tslib": "^2.4.0"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
@@ -63,6 +134,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@jsep-plugin/assignment": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz",
@@ -112,9 +194,9 @@
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
"integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -131,9 +213,9 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.124.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz",
"integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==",
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
"integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==",
"dev": true,
"license": "MIT",
"funding": {
@@ -148,9 +230,9 @@
"license": "MIT"
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz",
"integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==",
"cpu": [
"arm64"
],
@@ -165,9 +247,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz",
"integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==",
"cpu": [
"arm64"
],
@@ -182,9 +264,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz",
"integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz",
"integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==",
"cpu": [
"x64"
],
@@ -199,9 +281,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz",
"integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz",
"integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==",
"cpu": [
"x64"
],
@@ -216,9 +298,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz",
"integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz",
"integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==",
"cpu": [
"arm"
],
@@ -233,9 +315,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==",
"cpu": [
"arm64"
],
@@ -253,9 +335,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz",
"integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz",
"integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==",
"cpu": [
"arm64"
],
@@ -273,9 +355,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==",
"cpu": [
"ppc64"
],
@@ -293,9 +375,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==",
"cpu": [
"s390x"
],
@@ -313,9 +395,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==",
"cpu": [
"x64"
],
@@ -333,9 +415,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz",
"integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz",
"integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==",
"cpu": [
"x64"
],
@@ -353,9 +435,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz",
"integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==",
"cpu": [
"arm64"
],
@@ -370,9 +452,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz",
"integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz",
"integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==",
"cpu": [
"wasm32"
],
@@ -380,18 +462,18 @@
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "1.9.2",
"@emnapi/runtime": "1.9.2",
"@napi-rs/wasm-runtime": "^1.1.3"
"@emnapi/core": "1.10.0",
"@emnapi/runtime": "1.10.0",
"@napi-rs/wasm-runtime": "^1.1.4"
},
"engines": {
"node": ">=14.0.0"
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz",
"integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
"integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==",
"cpu": [
"arm64"
],
@@ -406,9 +488,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz",
"integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz",
"integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==",
"cpu": [
"x64"
],
@@ -423,9 +505,9 @@
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz",
"integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz",
"integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==",
"dev": true,
"license": "MIT"
},
@@ -506,17 +588,48 @@
"@types/node": "*"
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz",
"integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.5",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.2.0",
"magicast": "^0.5.2",
"obug": "^2.1.1",
"std-env": "^4.0.0-rc.1",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.1.5",
"vitest": "4.1.5"
},
"peerDependenciesMeta": {
"@vitest/browser": {
"optional": true
}
}
},
"node_modules/@vitest/expect": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz",
"integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
"integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.4",
"@vitest/utils": "4.1.4",
"@vitest/spy": "4.1.5",
"@vitest/utils": "4.1.5",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -525,13 +638,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz",
"integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
"integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.4",
"@vitest/spy": "4.1.5",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -552,9 +665,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz",
"integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
"integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -565,13 +678,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz",
"integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
"integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.4",
"@vitest/utils": "4.1.5",
"pathe": "^2.0.3"
},
"funding": {
@@ -579,14 +692,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz",
"integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
"integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.4",
"@vitest/utils": "4.1.4",
"@vitest/pretty-format": "4.1.5",
"@vitest/utils": "4.1.5",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -595,9 +708,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz",
"integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
"integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
"dev": true,
"license": "MIT",
"funding": {
@@ -605,13 +718,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz",
"integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
"integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.4",
"@vitest/pretty-format": "4.1.5",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -644,6 +757,18 @@
"node": ">=12"
}
},
"node_modules/ast-v8-to-istanbul": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz",
"integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"estree-walker": "^3.0.3",
"js-tokens": "^10.0.0"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -1050,6 +1175,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -1098,6 +1233,13 @@
"node": ">=14"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
@@ -1116,6 +1258,45 @@
"ws": "*"
}
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/jose": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
@@ -1125,6 +1306,13 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
@@ -1447,6 +1635,34 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/magicast": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz",
"integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0",
"source-map-js": "^1.2.1"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1591,9 +1807,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.9",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
"integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==",
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"dev": true,
"funding": [
{
@@ -1636,14 +1852,14 @@
"license": "MIT"
},
"node_modules/rolldown": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz",
"integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==",
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
"integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.124.0",
"@rolldown/pluginutils": "1.0.0-rc.15"
"@oxc-project/types": "=0.127.0",
"@rolldown/pluginutils": "1.0.0-rc.17"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -1652,21 +1868,34 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.15",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.15",
"@rolldown/binding-darwin-x64": "1.0.0-rc.15",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.15",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.15",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.15",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.15",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15"
"@rolldown/binding-android-arm64": "1.0.0-rc.17",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.17",
"@rolldown/binding-darwin-x64": "1.0.0-rc.17",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.17",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.17",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.17",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.17",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
}
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/siginfo": {
@@ -1758,6 +1987,19 @@
"text-decoder": "^1.1.0"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/tar-fs": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz",
@@ -1881,17 +2123,17 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "8.0.8",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz",
"integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
"version": "8.0.10",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
"integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.8",
"rolldown": "1.0.0-rc.15",
"tinyglobby": "^0.2.15"
"postcss": "^8.5.10",
"rolldown": "1.0.0-rc.17",
"tinyglobby": "^0.2.16"
},
"bin": {
"vite": "bin/vite.js"
@@ -1959,19 +2201,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz",
"integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==",
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
"integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.4",
"@vitest/mocker": "4.1.4",
"@vitest/pretty-format": "4.1.4",
"@vitest/runner": "4.1.4",
"@vitest/snapshot": "4.1.4",
"@vitest/spy": "4.1.4",
"@vitest/utils": "4.1.4",
"@vitest/expect": "4.1.5",
"@vitest/mocker": "4.1.5",
"@vitest/pretty-format": "4.1.5",
"@vitest/runner": "4.1.5",
"@vitest/snapshot": "4.1.5",
"@vitest/spy": "4.1.5",
"@vitest/utils": "4.1.5",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -1999,12 +2241,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.4",
"@vitest/browser-preview": "4.1.4",
"@vitest/browser-webdriverio": "4.1.4",
"@vitest/coverage-istanbul": "4.1.4",
"@vitest/coverage-v8": "4.1.4",
"@vitest/ui": "4.1.4",
"@vitest/browser-playwright": "4.1.5",
"@vitest/browser-preview": "4.1.5",
"@vitest/browser-webdriverio": "4.1.5",
"@vitest/coverage-istanbul": "4.1.5",
"@vitest/coverage-v8": "4.1.5",
"@vitest/ui": "4.1.5",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paperclip-adapter-opencode-k8s",
"version": "0.1.27",
"version": "0.1.36",
"description": "Paperclip adapter plugin that runs OpenCode agents as Kubernetes Jobs",
"license": "MIT",
"type": "module",
@@ -33,6 +33,7 @@
"devDependencies": {
"@paperclipai/adapter-utils": "2026.415.0-canary.7",
"@types/node": "^24.6.0",
"@vitest/coverage-v8": "^4.1.5",
"typescript": "^5.7.3",
"vitest": "^4.1.4"
}
+161 -12
View File
@@ -21,15 +21,39 @@ importers:
'@types/node':
specifier: ^24.6.0
version: 24.12.2
'@vitest/coverage-v8':
specifier: ^4.1.5
version: 4.1.5(vitest@4.1.5)
typescript:
specifier: ^5.7.3
version: 5.9.3
vitest:
specifier: ^4.1.4
version: 4.1.5(@types/node@24.12.2)(vite@8.0.10(@types/node@24.12.2))
version: 4.1.5(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@24.12.2))
packages:
'@babel/helper-string-parser@7.27.1':
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.28.5':
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
engines: {node: '>=6.9.0'}
'@babel/parser@7.29.2':
resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/types@7.29.0':
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@bcoe/v8-coverage@1.0.2':
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@emnapi/core@1.10.0':
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
@@ -39,9 +63,16 @@ packages:
'@emnapi/wasi-threads@1.2.1':
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@jsep-plugin/assignment@1.3.0':
resolution: {integrity: sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==}
engines: {node: '>= 10.16.0'}
@@ -104,42 +135,36 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.17':
resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17':
resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17':
resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.17':
resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0-rc.17':
resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0-rc.17':
resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==}
@@ -194,6 +219,15 @@ packages:
'@types/stream-buffers@3.0.8':
resolution: {integrity: sha512-J+7VaHKNvlNPJPEJXX/fKa9DZtR/xPMwuIbe+yNOwp1YB+ApUOBv2aUpEoBJEi8nJgbgs1x8e73ttg0r1rSUdw==}
'@vitest/coverage-v8@4.1.5':
resolution: {integrity: sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==}
peerDependencies:
'@vitest/browser': 4.1.5
vitest: 4.1.5
peerDependenciesMeta:
'@vitest/browser':
optional: true
'@vitest/expect@4.1.5':
resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==}
@@ -234,6 +268,9 @@ packages:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
ast-v8-to-istanbul@1.0.0:
resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
@@ -390,6 +427,10 @@ packages:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
@@ -406,6 +447,9 @@ packages:
resolution: {integrity: sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==}
engines: {node: '>=14'}
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
ip-address@10.1.0:
resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
engines: {node: '>= 12'}
@@ -415,9 +459,24 @@ packages:
peerDependencies:
ws: '*'
istanbul-lib-coverage@3.2.2:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
istanbul-lib-report@3.0.1:
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
engines: {node: '>=10'}
istanbul-reports@3.2.0:
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
engines: {node: '>=8'}
jose@6.2.2:
resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==}
js-tokens@10.0.0:
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
@@ -466,28 +525,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -508,6 +563,13 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
magicast@0.5.2:
resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==}
make-dir@4.0.0:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -574,6 +636,11 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
semver@7.7.4:
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
engines: {node: '>=10'}
hasBin: true
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
@@ -606,6 +673,10 @@ packages:
streamx@2.25.0:
resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
tar-fs@3.1.2:
resolution: {integrity: sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==}
@@ -759,6 +830,21 @@ packages:
snapshots:
'@babel/helper-string-parser@7.27.1': {}
'@babel/helper-validator-identifier@7.28.5': {}
'@babel/parser@7.29.2':
dependencies:
'@babel/types': 7.29.0
'@babel/types@7.29.0':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@bcoe/v8-coverage@1.0.2': {}
'@emnapi/core@1.10.0':
dependencies:
'@emnapi/wasi-threads': 1.2.1
@@ -775,8 +861,15 @@ snapshots:
tslib: 2.8.1
optional: true
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@jsep-plugin/assignment@1.3.0(jsep@1.4.0)':
dependencies:
jsep: 1.4.0
@@ -905,6 +998,20 @@ snapshots:
dependencies:
'@types/node': 24.12.2
'@vitest/coverage-v8@4.1.5(vitest@4.1.5)':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.1.5
ast-v8-to-istanbul: 1.0.0
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-reports: 3.2.0
magicast: 0.5.2
obug: 2.1.1
std-env: 4.1.0
tinyrainbow: 3.1.0
vitest: 4.1.5(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@24.12.2))
'@vitest/expect@4.1.5':
dependencies:
'@standard-schema/spec': 1.1.0
@@ -952,6 +1059,12 @@ snapshots:
assertion-error@2.0.1: {}
ast-v8-to-istanbul@1.0.0:
dependencies:
'@jridgewell/trace-mapping': 0.3.31
estree-walker: 3.0.3
js-tokens: 10.0.0
asynckit@0.4.0: {}
b4a@1.8.0: {}
@@ -1087,6 +1200,8 @@ snapshots:
gopd@1.2.0: {}
has-flag@4.0.0: {}
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
@@ -1099,14 +1214,31 @@ snapshots:
hpagent@1.2.0: {}
html-escaper@2.0.2: {}
ip-address@10.1.0: {}
isomorphic-ws@5.0.0(ws@8.20.0):
dependencies:
ws: 8.20.0
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-report@3.0.1:
dependencies:
istanbul-lib-coverage: 3.2.2
make-dir: 4.0.0
supports-color: 7.2.0
istanbul-reports@3.2.0:
dependencies:
html-escaper: 2.0.2
istanbul-lib-report: 3.0.1
jose@6.2.2: {}
js-tokens@10.0.0: {}
js-yaml@4.1.1:
dependencies:
argparse: 2.0.1
@@ -1172,6 +1304,16 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
magicast@0.5.2:
dependencies:
'@babel/parser': 7.29.2
'@babel/types': 7.29.0
source-map-js: 1.2.1
make-dir@4.0.0:
dependencies:
semver: 7.7.4
math-intrinsics@1.1.0: {}
mime-db@1.52.0: {}
@@ -1241,6 +1383,8 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17
'@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17
semver@7.7.4: {}
siginfo@2.0.0: {}
smart-buffer@4.2.0: {}
@@ -1275,6 +1419,10 @@ snapshots:
- bare-abort-controller
- react-native-b4a
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
tar-fs@3.1.2:
dependencies:
pump: 3.0.4
@@ -1342,7 +1490,7 @@ snapshots:
'@types/node': 24.12.2
fsevents: 2.3.3
vitest@4.1.5(@types/node@24.12.2)(vite@8.0.10(@types/node@24.12.2)):
vitest@4.1.5(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@24.12.2)):
dependencies:
'@vitest/expect': 4.1.5
'@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@24.12.2))
@@ -1366,6 +1514,7 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.12.2
'@vitest/coverage-v8': 4.1.5(vitest@4.1.5)
transitivePeerDependencies:
- msw
+178
View File
@@ -248,3 +248,181 @@ describe("formatEvent", () => {
});
});
});
import { parseStdoutLine } from "./format-event.js";
describe("parseStdoutLine (cli)", () => {
const TS = "2026-04-25T22:00:00.000Z";
it("returns empty for empty input", () => {
expect(parseStdoutLine("", TS)).toEqual([]);
expect(parseStdoutLine(" ", TS)).toEqual([]);
});
it("returns stdout entry for non-JSON input", () => {
expect(parseStdoutLine("plain log", TS)).toEqual([{ kind: "stdout", ts: TS, text: "plain log" }]);
});
it("returns stdout entry when JSON parses to a non-object primitive", () => {
expect(parseStdoutLine("42", TS)).toEqual([{ kind: "stdout", ts: TS, text: "42" }]);
});
it("renders a text event as an assistant delta", () => {
const line = JSON.stringify({ type: "text", part: { text: "Hello" } });
expect(parseStdoutLine(line, TS)).toEqual([{ kind: "assistant", ts: TS, text: "Hello", delta: true }]);
});
it("returns empty for text event with empty text", () => {
const line = JSON.stringify({ type: "text", part: { text: "" } });
expect(parseStdoutLine(line, TS)).toEqual([]);
});
it("renders tool_use status=error as tool_result with isError", () => {
const line = JSON.stringify({ type: "tool_use", part: { tool: "bash", id: "t1", state: { status: "error", error: "boom" } } });
expect(parseStdoutLine(line, TS)).toEqual([
{ kind: "tool_result", ts: TS, toolUseId: "t1", toolName: "bash", content: "boom", isError: true },
]);
});
it("uses 'Tool error' fallback when error event has no error string", () => {
const line = JSON.stringify({ type: "tool_use", part: { tool: "bash", id: "t1", state: { status: "error" } } });
const result = parseStdoutLine(line, TS);
expect((result[0] as { content: string }).content).toBe("Tool error");
});
it("renders tool_use status=completed as tool_result with output", () => {
const line = JSON.stringify({ type: "tool_use", part: { tool: "bash", id: "t1", state: { status: "completed", output: "ok" } } });
expect(parseStdoutLine(line, TS)).toEqual([
{ kind: "tool_result", ts: TS, toolUseId: "t1", toolName: "bash", content: "ok", isError: false },
]);
});
it("renders tool_use status=done — falls back to description when no output", () => {
const line = JSON.stringify({ type: "tool_use", part: { tool: "bash", id: "t1", state: { status: "done", description: "did it" } } });
expect((parseStdoutLine(line, TS)[0] as { content: string }).content).toBe("did it");
});
it("renders tool_use status=done — falls back to 'Done' when no output or description", () => {
const line = JSON.stringify({ type: "tool_use", part: { tool: "bash", id: "t1", state: { status: "done" } } });
expect((parseStdoutLine(line, TS)[0] as { content: string }).content).toBe("Done");
});
it("renders tool_use pending status as tool_call", () => {
const line = JSON.stringify({ type: "tool_use", part: { tool: "bash", id: "t1", state: { status: "running", description: "go" } } });
expect(parseStdoutLine(line, TS)).toEqual([
{ kind: "tool_call", ts: TS, name: "bash", input: "go", toolUseId: "t1" },
]);
});
it("falls back to part.type then 'tool' when no part.tool name", () => {
const line = JSON.stringify({ type: "tool_use", part: { type: "edit", state: { status: "running" } } });
expect((parseStdoutLine(line, TS)[0] as { name: string }).name).toBe("edit");
const line2 = JSON.stringify({ type: "tool_use", part: { state: { status: "running" } } });
expect((parseStdoutLine(line2, TS)[0] as { name: string }).name).toBe("tool");
});
it("renders step_finish with token/cost metrics", () => {
const line = JSON.stringify({
type: "step_finish",
part: {
message: "did the thing",
reason: "stop",
tokens: { input: 100, output: 50, reasoning: 10, cache: { read: 30 } },
cost: 0.0123,
},
});
const result = parseStdoutLine(line, TS);
expect(result).toEqual([{
kind: "result",
ts: TS,
text: "did the thing",
inputTokens: 100,
outputTokens: 60,
cachedTokens: 30,
costUsd: 0.0123,
subtype: "stop",
isError: false,
errors: [],
}]);
});
it("renders step_finish with default text when no message", () => {
const line = JSON.stringify({ type: "step_finish", part: { reason: "stop" } });
expect((parseStdoutLine(line, TS)[0] as { text: string }).text).toBe("Step finished: stop");
const line2 = JSON.stringify({ type: "step_finish", part: {} });
expect((parseStdoutLine(line2, TS)[0] as { text: string }).text).toBe("Step finished: done");
});
it("renders step_start as a system entry", () => {
const line = JSON.stringify({ type: "step_start" });
expect(parseStdoutLine(line, TS)).toEqual([{ kind: "system", ts: TS, text: "Starting step…" }]);
});
it("renders assistant event with nested text content", () => {
const line = JSON.stringify({
type: "assistant",
part: { message: { content: [{ type: "text", text: "hi there" }] } },
});
expect(parseStdoutLine(line, TS)).toEqual([{ kind: "assistant", ts: TS, text: "hi there" }]);
});
it("handles assistant content as a single non-array object", () => {
const line = JSON.stringify({
type: "assistant",
part: { message: { content: { type: "text", text: "single" } } },
});
expect(parseStdoutLine(line, TS)).toEqual([{ kind: "assistant", ts: TS, text: "single" }]);
});
it("returns empty for assistant event with no extractable text", () => {
const line = JSON.stringify({ type: "assistant", part: { message: { content: [{ type: "image" }] } } });
expect(parseStdoutLine(line, TS)).toEqual([]);
const line2 = JSON.stringify({ type: "assistant", part: {} });
expect(parseStdoutLine(line2, TS)).toEqual([]);
});
it("renders error event with errorText", () => {
const line = JSON.stringify({ type: "error", error: { message: "broken" } });
expect(parseStdoutLine(line, TS)).toEqual([{ kind: "stderr", ts: TS, text: "broken" }]);
});
it("returns empty for error event with empty error string", () => {
const line = JSON.stringify({ type: "error", error: "" });
expect(parseStdoutLine(line, TS)).toEqual([]);
});
it("uses error.code fallback in errorText", () => {
const line = JSON.stringify({ type: "error", error: { code: "E_X" } });
expect(parseStdoutLine(line, TS)).toEqual([{ kind: "stderr", ts: TS, text: "E_X" }]);
});
it("uses nested data.message and name fallbacks in errorText", () => {
const l1 = JSON.stringify({ type: "error", error: { data: { message: "nested" } } });
expect((parseStdoutLine(l1, TS)[0] as { text: string }).text).toBe("nested");
const l2 = JSON.stringify({ type: "error", error: { name: "ProviderErr" } });
expect((parseStdoutLine(l2, TS)[0] as { text: string }).text).toBe("ProviderErr");
});
it("falls back to JSON.stringify of the error object when nothing else matches", () => {
const line = JSON.stringify({ type: "error", error: { weirdKey: "x" } });
expect((parseStdoutLine(line, TS)[0] as { text: string }).text).toContain("weirdKey");
});
it("returns empty array for unknown event types", () => {
const line = JSON.stringify({ type: "totally_unknown" });
expect(parseStdoutLine(line, TS)).toEqual([]);
});
});
describe("formatEvent — additional coverage", () => {
it("returns empty for safeJsonParse of a non-object primitive", () => {
// formatEvent treats a non-object as non-JSON and returns the trimmed line as-is
const result = formatEvent("42", false);
expect(result).toBe("42");
});
it("returns empty for error event with empty error string", () => {
const line = JSON.stringify({ type: "error", error: "" });
expect(formatEvent(line, false)).toBe("");
});
});
+417 -6
View File
@@ -56,12 +56,13 @@ const HAPPY_JSONL = [
JSON.stringify({ type: "step_finish", part: { tokens: { input: 100, output: 50, cache: { read: 20 } }, cost: 0.002 } }),
].join("\n");
function makeCtx(configOverrides: Record<string, unknown> = {}, contextOverrides: Record<string, unknown> = {}): AdapterExecutionContext {
function makeCtx(configOverrides: Record<string, unknown> = {}, contextOverrides: Record<string, unknown> = {}, authToken = "test-auth-token"): AdapterExecutionContext {
return {
runId: "run-test-123",
agent: { id: "agent-id-test", name: "Test Agent", companyId: "co-1", adapterType: null, adapterConfig: null },
runtime: { sessionId: null, sessionParams: {}, sessionDisplayId: null, taskKey: null },
config: configOverrides,
authToken,
context: {
taskId: null,
issueId: null,
@@ -883,14 +884,13 @@ describe("execute — external cancel polling", () => {
vi.useRealTimers();
vi.unstubAllGlobals();
delete process.env.PAPERCLIP_API_URL;
delete process.env.PAPERCLIP_API_KEY;
delete process.env.PAPERCLIP_DEV_API_KEY;
});
it("returns errorCode=cancelled and deletes job when issue status is cancelled", async () => {
vi.useFakeTimers();
process.env.PAPERCLIP_API_URL = "http://test-api";
process.env.PAPERCLIP_API_KEY = "test-key";
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
@@ -913,7 +913,7 @@ describe("execute — external cancel polling", () => {
});
vi.mocked(getBatchApi).mockReturnValue(batchApi as unknown as ReturnType<typeof getBatchApi>);
const ctx = makeCtx({}, { issueId: "issue-test-456" });
const ctx = makeCtx({}, { issueId: "issue-test-456" }, "run-jwt-token");
const executePromise = execute(ctx);
// Advance in 1-second steps. vi.advanceTimersByTimeAsync fires fake timers
@@ -934,7 +934,7 @@ describe("execute — external cancel polling", () => {
);
expect(fetchMock).toHaveBeenCalledWith(
"http://test-api/api/issues/issue-test-456",
expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer test-key" }) }),
expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer run-jwt-token" }) }),
);
});
@@ -953,7 +953,6 @@ describe("execute — external cancel polling", () => {
vi.useFakeTimers();
process.env.PAPERCLIP_API_URL = "http://test-api";
process.env.PAPERCLIP_API_KEY = "test-key";
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: true,
@@ -971,6 +970,42 @@ describe("execute — external cancel polling", () => {
expect(result.errorCode).toBeUndefined();
expect(result.exitCode).toBe(0);
});
it("uses PAPERCLIP_DEV_API_KEY over ctx.authToken when set", async () => {
vi.useFakeTimers();
process.env.PAPERCLIP_API_URL = "http://test-api";
process.env.PAPERCLIP_DEV_API_KEY = "dev-override-key";
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ status: "cancelled" }),
});
vi.stubGlobal("fetch", fetchMock);
let jobDeleted = false;
const batchApi = makeBatchApi();
batchApi.deleteNamespacedJob.mockImplementation(() => { jobDeleted = true; return Promise.resolve({}); });
batchApi.readNamespacedJob.mockImplementation(() => {
if (jobDeleted) return Promise.reject(Object.assign(new Error("not found"), { statusCode: 404 }));
return Promise.resolve({ status: { conditions: [] } });
});
vi.mocked(getBatchApi).mockReturnValue(batchApi as unknown as ReturnType<typeof getBatchApi>);
const ctx = makeCtx({}, { issueId: "issue-test-456" }, "ctx-auth-token");
const executePromise = execute(ctx);
for (let i = 0; i < 20; i++) {
await vi.advanceTimersByTimeAsync(1_000);
}
await executePromise;
expect(fetchMock).toHaveBeenCalledWith(
"http://test-api/api/issues/issue-test-456",
expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer dev-override-key" }) }),
);
});
});
describe("execute — large-prompt Secret path", () => {
@@ -1212,3 +1247,379 @@ describe("isK8s404", () => {
expect(isK8s404(null)).toBe(false);
});
});
describe("parseModelProvider", () => {
it("returns null for null input", async () => {
const { parseModelProvider } = await import("./execute.js");
expect(parseModelProvider(null)).toBeNull();
});
it("returns null when model has no slash separator", async () => {
const { parseModelProvider } = await import("./execute.js");
expect(parseModelProvider("gpt-4")).toBeNull();
expect(parseModelProvider(" ")).toBeNull();
});
it("returns the provider segment from a slash-separated model id", async () => {
const { parseModelProvider } = await import("./execute.js");
expect(parseModelProvider("anthropic/claude-opus-4")).toBe("anthropic");
expect(parseModelProvider("openai/gpt-4o")).toBe("openai");
});
it("trims whitespace inside the provider segment", async () => {
const { parseModelProvider } = await import("./execute.js");
expect(parseModelProvider(" bedrock /claude")).toBe("bedrock");
});
it("returns null when provider segment is whitespace only", async () => {
const { parseModelProvider } = await import("./execute.js");
expect(parseModelProvider(" /model")).toBeNull();
});
});
describe("completionWithGrace", () => {
it("returns the completion result when it resolves before grace expires", async () => {
const { completionWithGrace } = await import("./execute.js");
const result = await completionWithGrace(
Promise.resolve({ succeeded: true, timedOut: false, jobGone: false }),
1000,
);
expect(result).toEqual({ succeeded: true, timedOut: false, jobGone: false });
});
it("returns timedOut result when grace expires first", async () => {
const { completionWithGrace } = await import("./execute.js");
vi.useFakeTimers();
try {
const slowCompletion = new Promise<{ succeeded: boolean; timedOut: boolean; jobGone: boolean }>(() => {});
const racePromise = completionWithGrace(slowCompletion, 50);
await vi.advanceTimersByTimeAsync(60);
const result = await racePromise;
expect(result).toEqual({ succeeded: false, timedOut: true, jobGone: false });
} finally {
vi.useRealTimers();
}
});
it("returns timedOut result when completion promise rejects", async () => {
const { completionWithGrace } = await import("./execute.js");
const result = await completionWithGrace(Promise.reject(new Error("boom")), 1000);
expect(result).toEqual({ succeeded: false, timedOut: true, jobGone: false });
});
});
describe("execute — config edge paths", () => {
it("logs a warning but continues when instructionsFilePath cannot be read", async () => {
const ctx = makeCtx({ instructionsFilePath: "/does/not/exist/AGENTS.md" });
const result = await execute(ctx);
expect(result.errorCode).toBeUndefined();
const logCalls = vi.mocked(ctx.onLog).mock.calls;
const warning = logCalls.find(([_kind, msg]: [string, string]) => typeof msg === "string" && msg.includes("instructionsFilePath not readable"));
expect(warning).toBeDefined();
});
it("returns k8s_job_create_failed when ensureAgentDbPvc throws (PVC create rejected)", async () => {
vi.mocked(getPvc).mockResolvedValueOnce(null);
vi.mocked(createPvc).mockRejectedValueOnce(new Error("storage class missing"));
const ctx = makeCtx({
agentDbMode: "dedicated_pvc",
agentDbStorageClass: "fast",
});
const result = await execute(ctx);
expect(result.errorCode).toBe("k8s_job_create_failed");
expect(result.errorMessage).toContain("storage class missing");
});
it("returns k8s_job_create_failed when ensureAgentDbPvc throws because storage class is missing", async () => {
vi.mocked(getPvc).mockResolvedValueOnce(null);
const ctx = makeCtx({ agentDbMode: "dedicated_pvc" });
const result = await execute(ctx);
expect(result.errorCode).toBe("k8s_job_create_failed");
expect(result.errorMessage).toContain("agentDbStorageClass is required");
});
});
describe("execute — large-prompt Secret create failure", () => {
const LARGE_PROMPT = "y".repeat(300 * 1024);
it("returns k8s_job_create_failed when createNamespacedSecret throws", async () => {
vi.mocked(buildJobManifest).mockReturnValue({
job: MOCK_JOB as ReturnType<typeof buildJobManifest>["job"],
jobName: JOB_NAME,
namespace: NAMESPACE,
prompt: LARGE_PROMPT,
opencodeArgs: [],
promptMetrics: null,
} as unknown as ReturnType<typeof buildJobManifest>);
const coreApi = makeCoreApi();
coreApi.createNamespacedSecret.mockRejectedValue(new Error("etcd full"));
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
const ctx = makeCtx();
const result = await execute(ctx);
expect(result.errorCode).toBe("k8s_job_create_failed");
expect(result.errorMessage).toContain("Failed to create prompt Secret");
expect(result.errorMessage).toContain("etcd full");
});
});
describe("ensureAgentDbPvc — verification failure (FAR-85 belt-and-suspenders)", () => {
it("throws when getPvc returns null after createPvc resolved (verification failed)", async () => {
vi.mocked(getPvc)
.mockResolvedValueOnce(null) // first existence check: not found
.mockResolvedValueOnce(null); // post-create verification: still not found
vi.mocked(createPvc).mockResolvedValueOnce({} as never);
await expect(
ensureAgentDbPvc("agent-x", "ns-x", { agentDbMode: "dedicated_pvc", agentDbStorageClass: "fast" }),
).rejects.toThrow(/PVC opencode-db-agent-x was not created/);
});
});
describe("execute — step limit detection", () => {
it("logs that the step limit was reached when a step_finish event has reason=max_steps", async () => {
const STEP_LIMIT_JSONL = [
JSON.stringify({ type: "text", part: { text: "partial" }, sessionID: "ses_step" }),
JSON.stringify({ type: "step_finish", part: { reason: "max_steps", tokens: { input: 10, output: 5 }, cost: 0 } }),
].join("\n");
const coreApi = makeCoreApi(STEP_LIMIT_JSONL, 0);
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
const ctx = makeCtx();
await execute(ctx);
const logCalls = vi.mocked(ctx.onLog).mock.calls;
const limitLog = logCalls.find(
([_kind, msg]: [string, string]) => typeof msg === "string" && msg.includes("step limit reached"),
);
expect(limitLog).toBeDefined();
});
});
describe("execute — waitForPod 'no pod yet' messaging", () => {
it("emits a 'Waiting for Job controller to create pod' log when pod is not yet present", async () => {
const coreApi = makeCoreApi();
// First listNamespacedPod call returns empty (no pod yet), second returns Running
coreApi.listNamespacedPod = vi.fn()
.mockResolvedValueOnce({ items: [] })
.mockResolvedValueOnce({
items: [{ metadata: { name: POD_NAME }, status: { phase: "Running" } }],
})
.mockResolvedValue({
items: [{ status: { containerStatuses: [{ name: "opencode", state: { terminated: { exitCode: 0 } } }] } }],
});
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
const ctx = makeCtx();
await execute(ctx);
const logCalls = vi.mocked(ctx.onLog).mock.calls;
const waitLog = logCalls.find(
([_kind, msg]: [string, string]) => typeof msg === "string" && msg.includes("Waiting for Job controller to create pod"),
);
expect(waitLog).toBeDefined();
});
});
describe("execute — pod scheduling failure (extra paths)", () => {
it("returns k8s_pod_schedule_failed when init container is in ImagePullBackOff", async () => {
const coreApi = {
listNamespacedPod: vi.fn().mockResolvedValue({
items: [
{
metadata: { name: POD_NAME },
status: {
phase: "Pending",
initContainerStatuses: [
{ name: "write-prompt", state: { waiting: { reason: "ImagePullBackOff", message: "back-off" } } },
],
},
},
],
}),
readNamespacedPodLog: vi.fn().mockResolvedValue(""),
};
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
const result = await execute(makeCtx());
expect(result.errorCode).toBe("k8s_pod_schedule_failed");
expect(result.errorMessage).toMatch(/Init container.*image pull failed/);
});
it("returns k8s_pod_schedule_failed when init container is in CrashLoopBackOff", async () => {
const coreApi = {
listNamespacedPod: vi.fn().mockResolvedValue({
items: [
{
metadata: { name: POD_NAME },
status: {
phase: "Pending",
initContainerStatuses: [
{ name: "write-prompt", state: { waiting: { reason: "CrashLoopBackOff", message: "loop" } } },
],
},
},
],
}),
readNamespacedPodLog: vi.fn().mockResolvedValue(""),
};
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
const result = await execute(makeCtx());
expect(result.errorCode).toBe("k8s_pod_schedule_failed");
expect(result.errorMessage).toMatch(/Init container.*crash loop/);
});
it("returns k8s_pod_schedule_failed when main container is in CrashLoopBackOff", async () => {
const coreApi = {
listNamespacedPod: vi.fn().mockResolvedValue({
items: [
{
metadata: { name: POD_NAME },
status: {
phase: "Pending",
containerStatuses: [
{ name: "opencode", state: { waiting: { reason: "CrashLoopBackOff", message: "loop" } } },
],
},
},
],
}),
readNamespacedPodLog: vi.fn().mockResolvedValue(""),
};
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
const result = await execute(makeCtx());
expect(result.errorCode).toBe("k8s_pod_schedule_failed");
expect(result.errorMessage).toMatch(/crash loop/);
});
it("proceeds when all init containers terminated successfully and main is running", async () => {
const coreApi = {
listNamespacedPod: vi.fn()
.mockResolvedValueOnce({
items: [
{
metadata: { name: POD_NAME },
status: {
phase: "Pending",
initContainerStatuses: [
{ name: "write-prompt", state: { terminated: { exitCode: 0 } } },
],
containerStatuses: [{ name: "opencode", state: { running: {} } }],
},
},
],
})
.mockResolvedValue({
items: [{ status: { containerStatuses: [{ name: "opencode", state: { terminated: { exitCode: 0 } } }] } }],
}),
readNamespacedPodLog: vi.fn().mockResolvedValue(HAPPY_JSONL),
};
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
const result = await execute(makeCtx());
expect(result.errorCode).toBeUndefined();
expect(result.exitCode).toBe(0);
});
});
describe("execute — skill bundle source loading", () => {
it("reads SKILL.md from entry.source dir and bundles content into the prompt", async () => {
const { mkdtempSync, writeFileSync, mkdirSync } = await import("node:fs");
const os = await import("node:os");
const path = await import("node:path");
const tmpDir = mkdtempSync(path.join(os.tmpdir(), "skills-test-"));
const skillDir = path.join(tmpDir, "skill-a");
mkdirSync(skillDir);
writeFileSync(path.join(skillDir, "SKILL.md"), "skill A content");
const utils = await import("@paperclipai/adapter-utils/server-utils");
vi.mocked(utils.readPaperclipRuntimeSkillEntries).mockResolvedValueOnce([
{ key: "paperclip/skill-a", runtimeName: "skill-a", source: skillDir, required: true } as never,
]);
const ctx = makeCtx();
await execute(ctx);
// buildJobManifest should have received the skills bundle content
const buildArgs = vi.mocked(buildJobManifest).mock.calls[0][0];
expect(buildArgs.skillsBundleContent).toContain("skill A content");
});
it("falls back to reading entry.source as a file when SKILL.md path read throws", async () => {
const { mkdtempSync, writeFileSync } = await import("node:fs");
const os = await import("node:os");
const path = await import("node:path");
const tmpDir = mkdtempSync(path.join(os.tmpdir(), "skills-flat-"));
const skillFile = path.join(tmpDir, "skill-b.md");
writeFileSync(skillFile, "skill B flat content");
const utils = await import("@paperclipai/adapter-utils/server-utils");
vi.mocked(utils.readPaperclipRuntimeSkillEntries).mockResolvedValueOnce([
{ key: "paperclip/skill-b", runtimeName: "skill-b", source: skillFile, required: true } as never,
]);
const ctx = makeCtx();
await execute(ctx);
const buildArgs = vi.mocked(buildJobManifest).mock.calls[0][0];
expect(buildArgs.skillsBundleContent).toContain("skill B flat content");
});
});
describe("execute — SIGTERM handler body (FAR-86 coverage)", () => {
it("invoking the captured SIGTERM handler deletes tracked Jobs and Secrets", async () => {
// Force a fresh module so sigtermHandlerInstalled starts false again.
vi.resetModules();
vi.doMock("./k8s-client.js", () => ({
getSelfPodInfo: vi.fn().mockResolvedValue(MOCK_SELF_POD),
getBatchApi: vi.fn(),
getCoreApi: vi.fn(),
getLogApi: vi.fn(),
getPvc: vi.fn().mockResolvedValue({ metadata: { name: "opencode-db-x" } }),
createPvc: vi.fn().mockResolvedValue({}),
}));
vi.doMock("./job-manifest.js", () => ({
buildJobManifest: vi.fn().mockReturnValue({
job: MOCK_JOB,
jobName: "fresh-job",
namespace: NAMESPACE,
prompt: "p",
opencodeArgs: [],
promptMetrics: null,
}),
LARGE_PROMPT_THRESHOLD_BYTES: 256 * 1024,
}));
const fresh = await import("./execute.js");
const k8s = await import("./k8s-client.js");
const batchApi = makeBatchApi();
const coreApi = makeCoreApi();
const logApi = makeLogApi();
vi.mocked(k8s.getBatchApi).mockReturnValue(batchApi as unknown as ReturnType<typeof k8s.getBatchApi>);
vi.mocked(k8s.getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof k8s.getCoreApi>);
vi.mocked(k8s.getLogApi).mockReturnValue(logApi as unknown as ReturnType<typeof k8s.getLogApi>);
let capturedHandler: (() => void) | null = null;
const onceSpy = vi.spyOn(process, "once").mockImplementation(
(event: string | symbol, handler: (...args: unknown[]) => void) => {
if (event === "SIGTERM") capturedHandler = handler as () => void;
return process;
},
);
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as never);
await fresh.execute(makeCtx());
onceSpy.mockRestore();
expect(capturedHandler).not.toBeNull();
(capturedHandler as unknown as () => void)();
// Wait long enough for the async handler body to settle
await new Promise((r) => setTimeout(r, 50));
expect(batchApi.deleteNamespacedJob).toHaveBeenCalled();
expect(exitSpy).toHaveBeenCalled();
exitSpy.mockRestore();
vi.doUnmock("./k8s-client.js");
vi.doUnmock("./job-manifest.js");
});
});
+21 -5
View File
@@ -37,7 +37,7 @@ export function isK8s404(err: unknown): boolean {
return false;
}
function parseModelProvider(model: string | null): string | null {
export function parseModelProvider(model: string | null): string | null {
if (!model) return null;
const trimmed = model.trim();
if (!trimmed.includes("/")) return null;
@@ -126,7 +126,11 @@ async function waitForPod(
(c) => c.type === "PodScheduled" && c.status === "False" && c.reason === "Unschedulable",
);
if (unschedulable) {
throw new Error(`Pod unschedulable: ${unschedulable.message ?? "insufficient resources"}`);
const msg = unschedulable.message ?? "insufficient resources";
if (/pvc|volume|bind|mount/i.test(msg)) {
throw new Error(`PVC bind failed: ${msg}`);
}
throw new Error(`Pod unschedulable: ${msg}`);
}
for (const cs of containerStatuses) {
@@ -137,6 +141,18 @@ async function waitForPod(
if (waiting?.reason === "CrashLoopBackOff") {
throw new Error(`Container "${cs.name}" crash loop: ${waiting.message ?? waiting.reason}`);
}
if (waiting?.reason === "MountVolumeFailed" || waiting?.reason === "ContainerCannotMount") {
throw new Error(`Volume mount failed for "${cs.name}": ${waiting.message ?? waiting.reason}`);
}
}
for (const cs of containerStatuses) {
const terminated = cs.state?.terminated;
if (terminated?.exitCode !== undefined && terminated.exitCode !== 0) {
if (terminated.reason === "ContainerCannotMount" || terminated.reason === "MountVolumeFailed") {
throw new Error(`Volume mount failed for "${cs.name}": ${terminated.message ?? terminated.reason}`);
}
}
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
@@ -553,9 +569,9 @@ async function streamAndAwaitJob(
await new Promise<void>((resolve) => setTimeout(resolve, KEEPALIVE_INTERVAL_MS));
if (logStopSignal.stopped || cancelSignal.cancelled) break;
try {
// Prefer PAPERCLIP_DEV_API_KEY if set (allows dev instance key to be
// distinct from the main-instance run JWT in PAPERCLIP_API_KEY).
const apiKey = process.env.PAPERCLIP_DEV_API_KEY ?? process.env.PAPERCLIP_API_KEY ?? "";
// Prefer PAPERCLIP_DEV_API_KEY if set (dev override), otherwise use
// the per-run authToken issued by Paperclip for this execution.
const apiKey = process.env.PAPERCLIP_DEV_API_KEY ?? ctx.authToken ?? "";
const resp = await fetch(`${apiUrl}/api/issues/${issueId}`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
+2 -1
View File
@@ -1,7 +1,7 @@
import type { ServerAdapterModule } from "@paperclipai/adapter-utils";
import { getAdapterSessionManagement } from "@paperclipai/adapter-utils";
import { type, agentConfigurationDoc } from "../index.js";
import { listK8sModels } from "./models.js";
import { listK8sModels, STATIC_MODELS } from "./models.js";
import { execute } from "./execute.js";
import { testEnvironment } from "./test.js";
import { sessionCodec } from "./session.js";
@@ -14,6 +14,7 @@ export function createServerAdapter(): ServerAdapterModule {
execute,
testEnvironment,
sessionCodec,
models: STATIC_MODELS,
listModels: listK8sModels,
listSkills: listOpenCodeSkills,
syncSkills: syncOpenCodeSkills,
+124 -6
View File
@@ -44,6 +44,17 @@ describe("buildJobManifest", () => {
expect(container?.image).toBe("paperclip/paperclip:latest");
});
it("uses config.image when provided, overriding selfPod image", () => {
const ctxWithImage = {
...mockCtx,
config: { image: "my-custom-image:v1.2.3" },
};
const result = buildJobManifest({ ctx: ctxWithImage, selfPod: mockSelfPod });
const container = result.job.spec?.template?.spec?.containers?.[0];
expect(container?.image).toBe("my-custom-image:v1.2.3");
});
it("sets fsGroupChangePolicy to OnRootMismatch", () => {
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
@@ -287,16 +298,16 @@ describe("buildJobManifest", () => {
});
describe("agentDbClaimName — OPENCODE_DB env var", () => {
it("sets OPENCODE_DB to /opencode-db when agentDbClaimName is a string (dedicated PVC)", () => {
it("sets OPENCODE_DB to /opencode-db/opencode.db when agentDbClaimName is a string (dedicated PVC)", () => {
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod, agentDbClaimName: "opencode-db-agent-abc" });
const env = result.job.spec?.template?.spec?.containers?.[0].env ?? [];
expect(env.find((e) => e.name === "OPENCODE_DB")?.value).toBe("/opencode-db");
expect(env.find((e) => e.name === "OPENCODE_DB")?.value).toBe("/opencode-db/opencode.db");
});
it("sets OPENCODE_DB to /opencode-db when agentDbClaimName is null (ephemeral)", () => {
it("sets OPENCODE_DB to /opencode-db/opencode.db when agentDbClaimName is null (ephemeral)", () => {
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod, agentDbClaimName: null });
const env = result.job.spec?.template?.spec?.containers?.[0].env ?? [];
expect(env.find((e) => e.name === "OPENCODE_DB")?.value).toBe("/opencode-db");
expect(env.find((e) => e.name === "OPENCODE_DB")?.value).toBe("/opencode-db/opencode.db");
});
it("does not set OPENCODE_DB when agentDbClaimName is undefined", () => {
@@ -305,13 +316,13 @@ describe("agentDbClaimName — OPENCODE_DB env var", () => {
expect(env.find((e) => e.name === "OPENCODE_DB")).toBeUndefined();
});
it("replaces a user-provided OPENCODE_DB env override with /opencode-db", () => {
it("replaces a user-provided OPENCODE_DB env override with /opencode-db/opencode.db", () => {
const selfPod = { ...mockSelfPod, inheritedEnv: { OPENCODE_DB: "/user/override" } };
const result = buildJobManifest({ ctx: mockCtx, selfPod, agentDbClaimName: "opencode-db-agent-abc" });
const env = result.job.spec?.template?.spec?.containers?.[0].env ?? [];
const dbEntries = env.filter((e) => e.name === "OPENCODE_DB");
expect(dbEntries).toHaveLength(1);
expect(dbEntries[0].value).toBe("/opencode-db");
expect(dbEntries[0].value).toBe("/opencode-db/opencode.db");
});
});
@@ -406,3 +417,110 @@ describe("sanitizeLabelValue", () => {
expect(warned.length).toBe(1);
});
});
describe("buildJobManifest — env wiring branches", () => {
it("sets PAPERCLIP_WAKE_PAYLOAD_JSON when paperclipWake is provided", () => {
const ctx = { ...mockCtx, context: { ...mockCtx.context, paperclipWake: { reason: "issue_assigned", issue: { id: "x" } } } };
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
const env = result.job.spec?.template.spec?.containers[0]?.env ?? [];
expect(env.find((e) => e.name === "PAPERCLIP_WAKE_PAYLOAD_JSON")?.value).toBeTruthy();
});
it("forwards workspace context and AGENT_HOME from paperclipWorkspace", () => {
const ctx = {
...mockCtx,
context: {
...mockCtx.context,
paperclipWorkspace: {
cwd: "/work",
source: "main",
strategy: "shared",
workspaceId: "ws_1",
repoUrl: "https://example.com/r.git",
repoRef: "main",
branchName: "feature/x",
worktreePath: "/wt/x",
agentHome: "/home/agent",
},
},
};
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
const env = result.job.spec?.template.spec?.containers[0]?.env ?? [];
expect(env.find((e) => e.name === "PAPERCLIP_WORKSPACE_CWD")?.value).toBe("/work");
expect(env.find((e) => e.name === "PAPERCLIP_WORKSPACE_BRANCH")?.value).toBe("feature/x");
expect(env.find((e) => e.name === "AGENT_HOME")?.value).toBe("/home/agent");
});
it("sets PAPERCLIP_LINKED_ISSUE_IDS from non-empty issueIds array (skipping blanks)", () => {
const ctx = { ...mockCtx, context: { ...mockCtx.context, issueIds: ["a", " ", "b", null as unknown as string, "c"] } };
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
const env = result.job.spec?.template.spec?.containers[0]?.env ?? [];
expect(env.find((e) => e.name === "PAPERCLIP_LINKED_ISSUE_IDS")?.value).toBe("a,b,c");
});
it("encodes paperclipWorkspaces / paperclipRuntimeServiceIntents / paperclipRuntimeServices as JSON env", () => {
const ctx = {
...mockCtx,
context: {
...mockCtx.context,
paperclipWorkspaces: [{ id: "w1" }],
paperclipRuntimeServiceIntents: [{ name: "redis" }],
paperclipRuntimeServices: [{ name: "redis", url: "redis://r" }],
paperclipRuntimePrimaryUrl: "https://primary",
},
};
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
const env = result.job.spec?.template.spec?.containers[0]?.env ?? [];
expect(env.find((e) => e.name === "PAPERCLIP_WORKSPACES_JSON")?.value).toContain("w1");
expect(env.find((e) => e.name === "PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON")?.value).toContain("redis");
expect(env.find((e) => e.name === "PAPERCLIP_RUNTIME_SERVICES_JSON")?.value).toContain("redis://r");
expect(env.find((e) => e.name === "PAPERCLIP_RUNTIME_PRIMARY_URL")?.value).toBe("https://primary");
});
it("sets PAPERCLIP_API_KEY from ctx.authToken when provided", () => {
const ctx = { ...mockCtx, authToken: "tok_abc" };
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
const env = result.job.spec?.template.spec?.containers[0]?.env ?? [];
expect(env.find((e) => e.name === "PAPERCLIP_API_KEY")?.value).toBe("tok_abc");
});
it("inherits PAPERCLIP_API_URL and PAPERCLIP_DEV_API_KEY from selfPod inheritedEnv", () => {
const selfPod = {
...mockSelfPod,
inheritedEnv: { PAPERCLIP_API_URL: "http://api", PAPERCLIP_DEV_API_KEY: "dev_key" },
};
const result = buildJobManifest({ ctx: mockCtx, selfPod });
const env = result.job.spec?.template.spec?.containers[0]?.env ?? [];
expect(env.find((e) => e.name === "PAPERCLIP_API_URL")?.value).toBe("http://api");
expect(env.find((e) => e.name === "PAPERCLIP_DEV_API_KEY")?.value).toBe("dev_key");
});
});
describe("buildJobManifest — volume wiring branches", () => {
it("mounts the prompt secret volume when promptSecretName is provided", () => {
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod, promptSecretName: "prompt-x" });
const volumes = result.job.spec?.template.spec?.volumes ?? [];
expect(volumes.find((v) => v.name === "prompt-secret")?.secret?.secretName).toBe("prompt-x");
});
it("mounts the data PVC at /paperclip when selfPod has a pvcClaimName", () => {
const selfPod = { ...mockSelfPod, pvcClaimName: "paperclip-data" };
const result = buildJobManifest({ ctx: mockCtx, selfPod });
const volumes = result.job.spec?.template.spec?.volumes ?? [];
expect(volumes.find((v) => v.name === "data")?.persistentVolumeClaim?.claimName).toBe("paperclip-data");
const mounts = result.job.spec?.template.spec?.containers[0]?.volumeMounts ?? [];
expect(mounts.find((m) => m.name === "data")?.mountPath).toBe("/paperclip");
});
it("mounts inherited secret volumes from selfPod.secretVolumes", () => {
const selfPod = {
...mockSelfPod,
secretVolumes: [{ volumeName: "tls", secretName: "tls-secret", mountPath: "/etc/tls", defaultMode: 0o400 }],
};
const result = buildJobManifest({ ctx: mockCtx, selfPod });
const volumes = result.job.spec?.template.spec?.volumes ?? [];
expect(volumes.find((v) => v.name === "tls")?.secret?.secretName).toBe("tls-secret");
const mounts = result.job.spec?.template.spec?.containers[0]?.volumeMounts ?? [];
expect(mounts.find((m) => m.name === "tls")).toEqual({ name: "tls", mountPath: "/etc/tls", readOnly: true });
});
});
+2 -2
View File
@@ -312,9 +312,9 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
if (input.agentDbClaimName !== undefined) {
const dbEnvIdx = envVars.findIndex((e) => e.name === "OPENCODE_DB");
if (dbEnvIdx >= 0) {
envVars[dbEnvIdx] = { name: "OPENCODE_DB", value: "/opencode-db" };
envVars[dbEnvIdx] = { name: "OPENCODE_DB", value: "/opencode-db/opencode.db" };
} else {
envVars.push({ name: "OPENCODE_DB", value: "/opencode-db" });
envVars.push({ name: "OPENCODE_DB", value: "/opencode-db/opencode.db" });
}
}
+296
View File
@@ -0,0 +1,296 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
/**
* Regression coverage for FAR-85: the @kubernetes/client-node v1.x ApiException
* exposes the HTTP status as `code`, not `statusCode`. The previous `isNotFound`
* predicate only checked `statusCode`/`response.statusCode`, so real 404s were
* never recognized — `getPvc` re-threw the 404 instead of returning null, and
* `ensureAgentDbPvc`'s existence check died before the create path ran.
*
* These tests mock the underlying k8s SDK and feed `getPvc`/`deletePvc` errors
* shaped exactly like the real ApiException so the predicate is exercised
* end-to-end, not in isolation.
*/
vi.mock("@kubernetes/client-node", () => {
// Reproduces the real @kubernetes/client-node v1.x ApiException shape:
// HTTP status under `code`, plus `body` and `headers`. Defined inside the
// factory because vi.mock() is hoisted above any module-level declarations.
class ApiException<T> extends Error {
code: number;
body: T;
headers: Record<string, string>;
constructor(code: number, message: string, body: T, headers: Record<string, string> = {}) {
super(`HTTP-Code: ${code}\nMessage: ${message}\nBody: ${JSON.stringify(body)}`);
this.code = code;
this.body = body;
this.headers = headers;
}
}
class KubeConfig {
loadFromCluster = mockLoadFromCluster;
loadFromFile = mockLoadFromFile;
makeApiClient() {
return {
readNamespacedPersistentVolumeClaim: mockReadNamespacedPVC,
deleteNamespacedPersistentVolumeClaim: mockDeleteNamespacedPVC,
createNamespacedPersistentVolumeClaim: mockCreateNamespacedPVC,
readNamespacedPod: mockReadNamespacedPod,
};
}
}
return {
KubeConfig,
CoreV1Api: class {},
BatchV1Api: class {},
AuthorizationV1Api: class {},
Log: class {},
ApiException,
};
});
const mockReadNamespacedPVC = vi.fn();
const mockDeleteNamespacedPVC = vi.fn();
const mockCreateNamespacedPVC = vi.fn();
const mockReadNamespacedPod = vi.fn();
const mockLoadFromCluster = vi.fn();
const mockLoadFromFile = vi.fn();
const mockReadFileSync = vi.fn();
vi.mock("node:fs", () => ({
readFileSync: (...args: unknown[]) => mockReadFileSync(...args),
}));
import * as k8s from "@kubernetes/client-node";
import { getPvc, createPvc, deletePvc, getSelfPodInfo, resetCache } from "./k8s-client.js";
const ApiException = (k8s as unknown as { ApiException: new <T>(code: number, message: string, body: T, headers?: Record<string, string>) => Error & { code: number; body: T } }).ApiException;
beforeEach(() => {
resetCache();
vi.resetAllMocks();
});
describe("getPvc — 404 detection (FAR-85 regression)", () => {
const NAMESPACE = "paperclip";
const NAME = "opencode-db-test";
it("returns the PVC on success", async () => {
const pvc = { metadata: { name: NAME, namespace: NAMESPACE } };
mockReadNamespacedPVC.mockResolvedValue(pvc);
const result = await getPvc(NAMESPACE, NAME);
expect(result).toEqual(pvc);
expect(mockReadNamespacedPVC).toHaveBeenCalledWith({ name: NAME, namespace: NAMESPACE });
});
it("returns null when the SDK throws ApiException with code=404 (v1.x shape)", async () => {
mockReadNamespacedPVC.mockRejectedValue(
new ApiException(404, "Unknown API Status Code!", {
kind: "Status",
status: "Failure",
message: `persistentvolumeclaims "${NAME}" not found`,
reason: "NotFound",
code: 404,
}),
);
const result = await getPvc(NAMESPACE, NAME);
expect(result).toBeNull();
});
it("returns null for legacy errors with statusCode=404", async () => {
mockReadNamespacedPVC.mockRejectedValue(Object.assign(new Error("not found"), { statusCode: 404 }));
expect(await getPvc(NAMESPACE, NAME)).toBeNull();
});
it("returns null for legacy errors with response.statusCode=404", async () => {
mockReadNamespacedPVC.mockRejectedValue(Object.assign(new Error("not found"), { response: { statusCode: 404 } }));
expect(await getPvc(NAMESPACE, NAME)).toBeNull();
});
it("re-throws non-404 ApiException (e.g. 500)", async () => {
const err = new ApiException(500, "Internal Error", { message: "boom" });
mockReadNamespacedPVC.mockRejectedValue(err);
await expect(getPvc(NAMESPACE, NAME)).rejects.toBe(err);
});
it("re-throws 403 (Forbidden) — must not be silently masked as missing", async () => {
const err = new ApiException(403, "Forbidden", { message: "rbac denied" });
mockReadNamespacedPVC.mockRejectedValue(err);
await expect(getPvc(NAMESPACE, NAME)).rejects.toBe(err);
});
});
describe("deletePvc — 404 detection", () => {
const NAMESPACE = "paperclip";
const NAME = "opencode-db-test";
it("swallows ApiException with code=404 (already gone)", async () => {
mockDeleteNamespacedPVC.mockRejectedValue(
new ApiException(404, "Unknown API Status Code!", { reason: "NotFound" }),
);
await expect(deletePvc(NAMESPACE, NAME)).resolves.toBeUndefined();
});
it("re-throws non-404 errors", async () => {
const err = new ApiException(409, "Conflict", { reason: "Conflict" });
mockDeleteNamespacedPVC.mockRejectedValue(err);
await expect(deletePvc(NAMESPACE, NAME)).rejects.toBe(err);
});
});
describe("createPvc — passes through to SDK", () => {
it("forwards the spec to createNamespacedPersistentVolumeClaim", async () => {
const spec = {
apiVersion: "v1",
kind: "PersistentVolumeClaim",
metadata: { name: "opencode-db-x", namespace: "paperclip" },
spec: { accessModes: ["ReadWriteOnce"], resources: { requests: { storage: "1Gi" } } },
};
mockCreateNamespacedPVC.mockResolvedValue(spec);
const result = await createPvc("paperclip", spec as never);
expect(result).toEqual(spec);
expect(mockCreateNamespacedPVC).toHaveBeenCalledWith({ namespace: "paperclip", body: spec });
});
});
describe("getSelfPodInfo", () => {
const HOSTNAME = "paperclip-test-pod";
const NAMESPACE = "paperclip-test";
beforeEach(() => {
process.env.HOSTNAME = HOSTNAME;
delete process.env.PAPERCLIP_NAMESPACE;
delete process.env.POD_NAMESPACE;
mockReadFileSync.mockReturnValue(NAMESPACE);
});
function basePod(overrides: Record<string, unknown> = {}) {
return {
spec: {
containers: [
{
name: "paperclip",
image: "paperclip:1.0",
env: [
{ name: "FOO", value: "bar" },
{ name: "SECRET_REF", valueFrom: { secretKeyRef: { name: "s", key: "k" } } },
],
envFrom: [{ configMapRef: { name: "cm" } }],
volumeMounts: [
{ name: "data", mountPath: "/paperclip" },
{ name: "tls-secret", mountPath: "/etc/tls" },
],
},
],
volumes: [
{ name: "data", persistentVolumeClaim: { claimName: "paperclip-pvc" } },
{ name: "tls-secret", secret: { secretName: "tls", defaultMode: 0o400 } },
],
imagePullSecrets: [{ name: "registry-creds" }, { name: "" }, {}],
dnsConfig: { nameservers: ["10.0.0.10"] },
...overrides,
},
};
}
it("introspects the pod and extracts image, env, PVC, secrets, dnsConfig", async () => {
mockReadNamespacedPod.mockResolvedValue(basePod());
const info = await getSelfPodInfo();
expect(info.namespace).toBe(NAMESPACE);
expect(info.image).toBe("paperclip:1.0");
expect(info.pvcClaimName).toBe("paperclip-pvc");
expect(info.inheritedEnv).toEqual({ FOO: "bar" });
expect(info.inheritedEnvValueFrom).toHaveLength(1);
expect(info.inheritedEnvValueFrom[0].name).toBe("SECRET_REF");
expect(info.inheritedEnvFrom).toHaveLength(1);
expect(info.secretVolumes).toEqual([
{ volumeName: "tls-secret", secretName: "tls", mountPath: "/etc/tls", defaultMode: 0o400 },
]);
// imagePullSecrets with empty name are filtered out
expect(info.imagePullSecrets).toEqual([{ name: "registry-creds" }]);
expect(info.dnsConfig).toEqual({ nameservers: ["10.0.0.10"] });
expect(mockReadNamespacedPod).toHaveBeenCalledWith({ name: HOSTNAME, namespace: NAMESPACE });
});
it("caches the result — second call does not re-query the API", async () => {
mockReadNamespacedPod.mockResolvedValue(basePod());
await getSelfPodInfo();
await getSelfPodInfo();
expect(mockReadNamespacedPod).toHaveBeenCalledTimes(1);
});
it("prefers PAPERCLIP_NAMESPACE env over service-account file", async () => {
process.env.PAPERCLIP_NAMESPACE = "from-env";
mockReadNamespacedPod.mockResolvedValue(basePod());
const info = await getSelfPodInfo();
expect(info.namespace).toBe("from-env");
expect(mockReadFileSync).not.toHaveBeenCalled();
});
it("falls back to POD_NAMESPACE when PAPERCLIP_NAMESPACE not set", async () => {
process.env.POD_NAMESPACE = "downward-api";
mockReadNamespacedPod.mockResolvedValue(basePod());
const info = await getSelfPodInfo();
expect(info.namespace).toBe("downward-api");
});
it("falls back to 'default' when service-account file read throws", async () => {
mockReadFileSync.mockImplementation(() => {
throw new Error("ENOENT");
});
mockReadNamespacedPod.mockResolvedValue(basePod());
const info = await getSelfPodInfo();
expect(info.namespace).toBe("default");
});
it("throws when HOSTNAME is not set", async () => {
delete process.env.HOSTNAME;
await expect(getSelfPodInfo()).rejects.toThrow("HOSTNAME env var not set");
});
it("throws when pod has no spec", async () => {
mockReadNamespacedPod.mockResolvedValue({ spec: null });
await expect(getSelfPodInfo()).rejects.toThrow("has no spec");
});
it("throws when main container has no image", async () => {
mockReadNamespacedPod.mockResolvedValue({
spec: { containers: [{ name: "paperclip", image: "" }] },
});
await expect(getSelfPodInfo()).rejects.toThrow("has no container image");
});
it("falls back to first container when no container is named 'paperclip'", async () => {
mockReadNamespacedPod.mockResolvedValue({
spec: { containers: [{ name: "other", image: "other:1.0" }] },
});
const info = await getSelfPodInfo();
expect(info.image).toBe("other:1.0");
});
it("returns null pvcClaimName when no /paperclip mount exists", async () => {
mockReadNamespacedPod.mockResolvedValue({
spec: { containers: [{ name: "paperclip", image: "p:1", volumeMounts: [] }] },
});
const info = await getSelfPodInfo();
expect(info.pvcClaimName).toBeNull();
});
it("returns null pvcClaimName when /paperclip mount is not backed by a PVC", async () => {
mockReadNamespacedPod.mockResolvedValue({
spec: {
containers: [{ name: "paperclip", image: "p:1", volumeMounts: [{ name: "data", mountPath: "/paperclip" }] }],
volumes: [{ name: "data", emptyDir: {} }],
},
});
const info = await getSelfPodInfo();
expect(info.pvcClaimName).toBeNull();
});
it("uses kubeconfig file path when provided (not in-cluster)", async () => {
mockReadNamespacedPod.mockResolvedValue(basePod());
await getSelfPodInfo("/tmp/kubeconfig.yaml");
expect(mockLoadFromFile).toHaveBeenCalledWith("/tmp/kubeconfig.yaml");
expect(mockLoadFromCluster).not.toHaveBeenCalled();
});
});
+154
View File
@@ -0,0 +1,154 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const runChildProcessMock = vi.fn();
vi.mock("@paperclipai/adapter-utils/server-utils", async (importOriginal) => {
const actual = await importOriginal<typeof import("@paperclipai/adapter-utils/server-utils")>();
return { ...actual, runChildProcess: runChildProcessMock };
});
const { listK8sModels, discoverK8sModels, resetK8sModelsCacheForTests } = await import("./models.js");
type MockResult = { exitCode: number | null; stdout: string; stderr: string; timedOut: boolean };
function mockSuccess(stdout: string): void {
runChildProcessMock.mockResolvedValue({ exitCode: 0, stdout, stderr: "", timedOut: false } satisfies MockResult);
}
function mockFailure(stderr = "ENOENT: opencode not found"): void {
runChildProcessMock.mockResolvedValue({ exitCode: 1, stdout: "", stderr, timedOut: false } satisfies MockResult);
}
function mockTimeout(): void {
runChildProcessMock.mockResolvedValue({ exitCode: null, stdout: "", stderr: "", timedOut: true } satisfies MockResult);
}
describe("listK8sModels", () => {
afterEach(() => {
runChildProcessMock.mockReset();
resetK8sModelsCacheForTests();
});
it("parses provider/model lines into AdapterModel entries", async () => {
mockSuccess(["anthropic/claude-opus-4-7", "openai/gpt-4o", "google/gemini-2.5-pro"].join("\n"));
const models = await listK8sModels();
expect(models).toHaveLength(3);
expect(models.find((m) => m.id === "anthropic/claude-opus-4-7")).toEqual({
id: "anthropic/claude-opus-4-7",
label: "anthropic/claude-opus-4-7",
});
});
it("ignores blank lines and trims whitespace", async () => {
mockSuccess("\nanthropic/claude-opus-4-7\n\n openai/gpt-4o \n\n");
const models = await listK8sModels();
expect(models.map((m) => m.id)).toEqual(
["anthropic/claude-opus-4-7", "openai/gpt-4o"].sort(),
);
});
it("skips lines without a provider/model slash", async () => {
mockSuccess("anthropic/claude-opus-4-7\nnot-a-model-line\nopenai/gpt-4o");
const models = await listK8sModels();
expect(models.map((m) => m.id)).not.toContain("not-a-model-line");
expect(models).toHaveLength(2);
});
it("deduplicates repeated model IDs", async () => {
mockSuccess("anthropic/claude-opus-4-7\nanthropic/claude-opus-4-7\nopenai/gpt-4o");
const models = await listK8sModels();
expect(models.filter((m) => m.id === "anthropic/claude-opus-4-7")).toHaveLength(1);
});
it("returns models sorted alphabetically by ID", async () => {
mockSuccess("openai/gpt-4o\nanthropic/claude-opus-4-7");
const models = await listK8sModels();
expect(models[0].id).toBe("anthropic/claude-opus-4-7");
expect(models[1].id).toBe("openai/gpt-4o");
});
it("returns empty array when the CLI fails", async () => {
mockFailure("ENOENT: opencode not found");
expect(await listK8sModels()).toEqual([]);
});
it("returns empty array when the CLI times out", async () => {
mockTimeout();
expect(await listK8sModels()).toEqual([]);
});
it("returns empty array when the CLI returns empty stdout", async () => {
mockSuccess("");
expect(await listK8sModels()).toEqual([]);
});
it("caches results and only calls the CLI once within the TTL", async () => {
mockSuccess("anthropic/claude-opus-4-7");
await listK8sModels();
await listK8sModels();
expect(runChildProcessMock).toHaveBeenCalledTimes(1);
});
it("re-fetches after the cache is reset", async () => {
mockSuccess("anthropic/claude-opus-4-7");
await listK8sModels();
resetK8sModelsCacheForTests();
await listK8sModels();
expect(runChildProcessMock).toHaveBeenCalledTimes(2);
});
});
describe("discoverK8sModels", () => {
afterEach(() => {
runChildProcessMock.mockReset();
resetK8sModelsCacheForTests();
});
it("passes OPENCODE_DISABLE_PROJECT_CONFIG=true to the subprocess", async () => {
mockSuccess("anthropic/claude-opus-4-7");
await discoverK8sModels();
const [, , , opts] = runChildProcessMock.mock.calls[0] as [unknown, unknown, unknown, { env: Record<string, string> }];
expect(opts.env).toMatchObject({ OPENCODE_DISABLE_PROJECT_CONFIG: "true" });
});
it("invokes opencode with the models subcommand", async () => {
mockSuccess("anthropic/claude-opus-4-7");
await discoverK8sModels();
const [, command, args] = runChildProcessMock.mock.calls[0] as [unknown, string, string[]];
expect(command).toBe("opencode");
expect(args).toEqual(["models"]);
});
it("throws when the CLI exits non-zero", async () => {
mockFailure("provider not configured");
await expect(discoverK8sModels()).rejects.toThrow("opencode models` failed");
});
it("throws when the CLI times out", async () => {
mockTimeout();
await expect(discoverK8sModels()).rejects.toThrow("timed out");
});
});
+173 -10
View File
@@ -1,15 +1,178 @@
import { createHash } from "node:crypto";
import os from "node:os";
import type { AdapterModel } from "@paperclipai/adapter-utils";
import { asString, ensurePathInEnv, runChildProcess } from "@paperclipai/adapter-utils/server-utils";
const MODELS: AdapterModel[] = [
{ id: "anthropic/claude-opus-4-7", label: "Claude Opus 4.7" },
{ id: "anthropic/claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
{ id: "anthropic/claude-haiku-4-5", label: "Claude Haiku 4.5" },
{ id: "openai/gpt-4o", label: "GPT-4o" },
{ id: "openai/gpt-4o-mini", label: "GPT-4o mini" },
{ id: "google/gemini-2.5-pro", label: "Gemini 2.5 Pro" },
{ id: "google/gemini-2.5-flash", label: "Gemini 2.5 Flash" },
export const STATIC_MODELS: AdapterModel[] = [
{ id: "anthropic/claude-opus-4-7", label: "anthropic/claude-opus-4-7" },
{ id: "anthropic/claude-sonnet-4-6", label: "anthropic/claude-sonnet-4-6" },
{ id: "anthropic/claude-haiku-4-5", label: "anthropic/claude-haiku-4-5" },
{ id: "openai/gpt-4o", label: "openai/gpt-4o" },
{ id: "google/gemini-2.5-pro", label: "google/gemini-2.5-pro" },
{ id: "google/gemini-2.5-flash", label: "google/gemini-2.5-flash" },
];
export async function listK8sModels(): Promise<AdapterModel[]> {
return MODELS;
const MODELS_CACHE_TTL_MS = 60_000;
const MODELS_DISCOVERY_TIMEOUT_MS = 20_000;
const discoveryCache = new Map<string, { expiresAt: number; models: AdapterModel[] }>();
const VOLATILE_ENV_KEY_PREFIXES = ["PAPERCLIP_", "npm_", "NPM_"] as const;
const VOLATILE_ENV_KEY_EXACT = new Set(["PWD", "OLDPWD", "SHLVL", "_", "TERM_SESSION_ID", "HOME"]);
function dedupeModels(models: AdapterModel[]): AdapterModel[] {
const seen = new Set<string>();
const deduped: AdapterModel[] = [];
for (const model of models) {
const id = model.id.trim();
if (!id || seen.has(id)) continue;
seen.add(id);
deduped.push({ id, label: model.label.trim() || id });
}
return deduped;
}
function sortModels(models: AdapterModel[]): AdapterModel[] {
return [...models].sort((a, b) =>
a.id.localeCompare(b.id, "en", { numeric: true, sensitivity: "base" }),
);
}
function firstNonEmptyLine(text: string): string {
return (
text
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? ""
);
}
function parseModelsOutput(stdout: string): AdapterModel[] {
const parsed: AdapterModel[] = [];
for (const raw of stdout.split(/\r?\n/)) {
const line = raw.trim();
if (!line) continue;
const firstToken = line.split(/\s+/)[0]?.trim() ?? "";
if (!firstToken.includes("/")) continue;
const provider = firstToken.slice(0, firstToken.indexOf("/")).trim();
const model = firstToken.slice(firstToken.indexOf("/") + 1).trim();
if (!provider || !model) continue;
parsed.push({ id: `${provider}/${model}`, label: `${provider}/${model}` });
}
return dedupeModels(parsed);
}
function normalizeEnv(input: unknown): Record<string, string> {
const envInput =
typeof input === "object" && input !== null && !Array.isArray(input)
? (input as Record<string, unknown>)
: {};
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(envInput)) {
if (typeof value === "string") env[key] = value;
}
return env;
}
function isVolatileEnvKey(key: string): boolean {
if (VOLATILE_ENV_KEY_EXACT.has(key)) return true;
return VOLATILE_ENV_KEY_PREFIXES.some((prefix) => key.startsWith(prefix));
}
function hashValue(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function discoveryCacheKey(command: string, cwd: string, env: Record<string, string>) {
const envKey = Object.entries(env)
.filter(([key]) => !isVolatileEnvKey(key))
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}=${hashValue(value)}`)
.join("\n");
return `${command}\n${cwd}\n${envKey}`;
}
function pruneExpiredDiscoveryCache(now: number) {
for (const [key, value] of discoveryCache.entries()) {
if (value.expiresAt <= now) discoveryCache.delete(key);
}
}
export async function discoverK8sModels(input: {
command?: unknown;
cwd?: unknown;
env?: unknown;
} = {}): Promise<AdapterModel[]> {
const command = asString(input.command, "opencode");
const cwd = asString(input.cwd, process.cwd());
const env = normalizeEnv(input.env);
let resolvedHome: string | undefined;
try {
resolvedHome = os.userInfo().homedir || undefined;
} catch {
// os.userInfo() throws when the UID has no /etc/passwd entry (e.g. distroless images)
}
const runtimeEnv = normalizeEnv(
ensurePathInEnv({
...process.env,
...env,
...(resolvedHome ? { HOME: resolvedHome } : {}),
OPENCODE_DISABLE_PROJECT_CONFIG: "true",
}),
);
const result = await runChildProcess(
`opencode-models-${Date.now()}-${Math.random().toString(16).slice(2)}`,
command,
["models"],
{
cwd,
env: runtimeEnv,
timeoutSec: MODELS_DISCOVERY_TIMEOUT_MS / 1000,
graceSec: 3,
onLog: async () => {},
},
);
if (result.timedOut) {
throw new Error(`\`opencode models\` timed out after ${MODELS_DISCOVERY_TIMEOUT_MS / 1000}s.`);
}
if ((result.exitCode ?? 1) !== 0) {
const detail = firstNonEmptyLine(result.stderr) || firstNonEmptyLine(result.stdout);
throw new Error(detail ? `\`opencode models\` failed: ${detail}` : "`opencode models` failed.");
}
return sortModels(parseModelsOutput(result.stdout));
}
export async function discoverK8sModelsCached(input: {
command?: unknown;
cwd?: unknown;
env?: unknown;
} = {}): Promise<AdapterModel[]> {
const command = asString(input.command, "opencode");
const cwd = asString(input.cwd, process.cwd());
const env = normalizeEnv(input.env);
const key = discoveryCacheKey(command, cwd, env);
const now = Date.now();
pruneExpiredDiscoveryCache(now);
const cached = discoveryCache.get(key);
if (cached && cached.expiresAt > now) return cached.models;
const models = await discoverK8sModels({ command, cwd, env });
discoveryCache.set(key, { expiresAt: now + MODELS_CACHE_TTL_MS, models });
return models;
}
export async function listK8sModels(): Promise<AdapterModel[]> {
try {
return await discoverK8sModelsCached();
} catch {
return [];
}
}
export function resetK8sModelsCacheForTests() {
discoveryCache.clear();
}
+42
View File
@@ -182,3 +182,45 @@ describe("isOpenCodeUnknownSessionError", () => {
expect(isOpenCodeUnknownSessionError(stdout, "")).toBe(true);
});
});
describe("parseOpenCodeJsonl — errorText fallback paths", () => {
it("uses nested data.message when top-level message is missing", () => {
const stdout = JSON.stringify({
type: "error",
error: { data: { message: "nested issue" } },
sessionID: "ses_x",
});
const result = parseOpenCodeJsonl(stdout);
expect(result.errorMessage).toContain("nested issue");
});
it("uses error.name when no message or nested message", () => {
const stdout = JSON.stringify({
type: "error",
error: { name: "ProviderAuthError" },
sessionID: "ses_x",
});
const result = parseOpenCodeJsonl(stdout);
expect(result.errorMessage).toContain("ProviderAuthError");
});
it("uses error.code when no message/name", () => {
const stdout = JSON.stringify({
type: "error",
error: { code: "E_TIMEOUT" },
sessionID: "ses_x",
});
const result = parseOpenCodeJsonl(stdout);
expect(result.errorMessage).toContain("E_TIMEOUT");
});
it("falls back to JSON.stringify of the error object when nothing matches", () => {
const stdout = JSON.stringify({
type: "error",
error: { unexpectedShape: { foo: "bar" } },
sessionID: "ses_x",
});
const result = parseOpenCodeJsonl(stdout);
expect(result.errorMessage).toContain("unexpectedShape");
});
});
+24
View File
@@ -0,0 +1,24 @@
import { describe, it, expect } from "vitest";
import { createServerAdapter } from "./index.js";
describe("createServerAdapter", () => {
it("declares the opencode_k8s type", () => {
const adapter = createServerAdapter();
expect(adapter.type).toBe("opencode_k8s");
});
it("exposes a non-empty static models list so the UI renders before listModels resolves", () => {
const adapter = createServerAdapter();
expect(Array.isArray(adapter.models)).toBe(true);
expect(adapter.models!.length).toBeGreaterThan(0);
for (const m of adapter.models!) {
expect(m.id).toMatch(/^[^/]+\/.+/);
expect(m.label).toBe(m.id);
}
});
it("exposes listModels for dynamic model discovery", () => {
const adapter = createServerAdapter();
expect(typeof adapter.listModels).toBe("function");
});
});
+38
View File
@@ -323,3 +323,41 @@ describe("parseStdoutLine", () => {
expect(parseStdoutLine(line, TS)).toEqual([]);
});
});
describe("parseStdoutLine — error edge cases", () => {
const TS_ERR = "2026-04-25T22:00:00.000Z";
it("returns stdout entry when JSON parses to a primitive (not an object)", () => {
const result = parseStdoutLine("42", TS_ERR);
// safeJsonParse returns null for non-object → falls through to stdout entry
expect(result).toEqual([{ kind: "stdout", ts: TS_ERR, text: "42" }]);
});
it("returns empty for text event with empty text", () => {
const line = JSON.stringify({ type: "text", part: { text: "" } });
expect(parseStdoutLine(line, TS_ERR)).toEqual([]);
});
it("returns empty for assistant event with no content blocks", () => {
const line = JSON.stringify({ type: "assistant", part: { message: { content: null } } });
expect(parseStdoutLine(line, TS_ERR)).toEqual([]);
});
it("returns empty for error event whose error field is an empty string", () => {
const line = JSON.stringify({ type: "error", error: "" });
expect(parseStdoutLine(line, TS_ERR)).toEqual([]);
});
it("uses error.code fallback when error has no message/data/name", () => {
const line = JSON.stringify({ type: "error", error: { code: "E_FOO" } });
const result = parseStdoutLine(line, TS_ERR);
expect(result).toEqual([{ kind: "stderr", ts: TS_ERR, text: "E_FOO" }]);
});
it("falls back to JSON.stringify of error object when no known field", () => {
const line = JSON.stringify({ type: "error", error: { somethingElse: "x" } });
const result = parseStdoutLine(line, TS_ERR);
expect(result[0].kind).toBe("stderr");
expect((result[0] as { text: string }).text).toContain("somethingElse");
});
});