From 58a36119d7bac49e9c28f6ed445a964ea41c8e38 Mon Sep 17 00:00:00 2001 From: Lutz Kind <132444134+lutzkind@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:24:20 +0000 Subject: [PATCH] fix(titles): restore automatic session titles and persist pending-title recovery OpenCode prepares automatic session naming as a separate title model request that uses the provider's small model (opencode-go/gpt-6-luna), not the session model. The built-in policy only listed the DeepSeek models, so every title request was UNKNOWN for a configured provider and failed closed at all hours: sessions completed without a name from the moment the plugin was installed, independent of peak pricing. - policy: add flatModels (vendor-documented all-hours pricing), explicit globs, models precedence, unmatched models still fail closed; the built-in Go policy declares the documented flat models and an unmodified stored copy adopts the list at read time - engine: a held title request persists a pending-title recovery (origin pending-title) keyed per origin so it can coexist with a primary deferral; delivery is a semantic check that never mutates the session, never overwrites manual or generated titles, and clears once OpenCode's own generation (triggered by the continuity promotion on every interruption path) names the session - guard: the transport hooks now receive the request kind and remove only their own recovery class - status: report titleRecoveryWaiting - tests/docs: 86 tests including flat-model pricing, pending-title semantics, and read-time policy adoption; architecture, pricing, changelog, decisions, and release notes updated --- ARCHITECTURE.md | 14 +- CHANGELOG.md | 8 + DECISIONS.md | 10 +- README.md | 8 +- RELEASING.md | 6 +- docs/pricing-policies.md | 9 +- docs/releases/v0.1.5.md | 42 ++++ docs/troubleshooting.md | 2 +- package.json | 2 +- src/config.ts | 25 ++- src/engine.ts | 254 ++++++++++++++++++++----- src/index.ts | 4 +- src/policy.ts | 65 ++++++- src/server.ts | 19 +- src/store.ts | 20 +- test/engine.test.mjs | 402 ++++++++++++++++++++++++++++++++++++++- test/policy.test.mjs | 106 +++++++++++ 17 files changed, 914 insertions(+), 82 deletions(-) create mode 100644 docs/releases/v0.1.5.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7731733..b5ecb0b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -60,6 +60,18 @@ The server plugin registers two lifecycle points: A queued `auto` task transitions to `WAITING_OFFPEAK` and waits at whichever point it reaches first (normally `model.request`); its existing override still applies. In `auto` mode, a normal interactive request for a supported model is deferred the same way. It creates no task and no session, is tracked only in server memory (shown by `/offpeak status`), and is released exactly once when the plugin is enabled and the policy allows. Wake-ups come from the shared config-change signal plus one timer scheduled for the policy's `nextTransition`; there is no polling loop. If the plugin shuts down while a request is held, the request fails closed as blocked rather than being sent under an uncertain pricing state. Requests in `queue`/`guard` mode, and requests that arrive while the plugin is OFF, are blocked immediately by the transport guard; OFF does not release a request that is already held. +### Auxiliary requests and pending titles + +OpenCode prepares auxiliary requests with the same session identity as the agent loop: `compaction` (context summarization inside a turn), `generate` (stateless text generation), and `title` (automatic chat naming). Compaction and generate share the turn's fate: if their hold is interrupted, the primary recovery continuation re-runs the step and the auxiliary request is prepared again. An automatic title does not: OpenCode generates it once when the first input of an untitled root session is promoted, and a lost title request is not retried by OpenCode itself. + +A held `title` request therefore persists a semantic `pending-title` recovery marker (same minimal shape as a deferral: session ID, provider/model, location, timestamp, origin) instead of the primary continuation. A session can hold a primary continuation and a pending-title marker at the same time. When the allowed window arrives the marker is delivered as a check, never as a replay or a mutation: + +- A session that already has a title — generated by OpenCode, generated by another process, or renamed by the user — clears the marker without any request, so a title is never overwritten and never generated twice. +- A session that still needs a title is left to OpenCode's own generation. Every path that loses a title hold (inactivity, supersede, shutdown, reload) also resumes the session — the plugin's same-session continuation or OpenCode's restart recovery — and that input promotion is OpenCode's native trigger for automatic naming. The marker waits for the title, clears once it appears, and is dropped with a diagnostic if no title appears within the settle cap; a later input promotion still names the session normally. +- OpenCode 2.0.27 exposes no plugin-facing regeneration call. The HTTP API's `PATCH /api/session/:id {"title": ""}` runs OpenCode's regeneration, but the plugin host's `session.update` (the only form a plugin can call) renames the session to an empty title instead. The recovery therefore mutates nothing; the `titleRecoveryWaiting` status field reports pending markers. + +`UNKNOWN` stays fail-closed for every kind, including titles: a model with no policy entry produces no provider traffic at any hour until the policy is extended. The built-in Go policy avoids that for the models OpenCode actually requests by declaring the provider's documented flat-priced models explicitly (see `src/policy.ts` and `docs/pricing-policies.md`). + ### Peak-aware retry recovery and the inactivity safety net OpenCode retries retryable transport failures on its own (for the agent loop: jittered exponential backoff; header-timeout failures are bounded to three retries). Each retry attempt is a new paid request, and every attempt re-enters the whole hook chain, so a retry during peak would either be held or exhaust the retry budget and fail the turn. @@ -73,7 +85,7 @@ OpenCode's location-activity sweep interrupts sessions whose location has had no Non-retryable failures (`provider.invalid-request`, `provider.auth`, `provider.quota`, `provider.content-filter`, …) are left untouched and still end as `FAILED`/error. Failures while pricing is allowed keep OpenCode's normal retry behavior. OFF, `queue`, and `guard` modes are unchanged. The continuation is a new state-aware turn; the failed provider request is never replayed byte-for-byte. A recovery whose session never settles is dropped without sending anything. -Policy matching uses provider IDs and exact/glob model IDs. A configured provider with an unknown model is `UNKNOWN` and blocked. Invalid timezone or schedule data is blocked. Providers without an installed policy are `UNTRACKED`; they are not covered by this plugin until a policy is added. Queue tasks must match a valid policy. +Policy matching uses provider IDs and exact/glob model IDs. Each policy declares its peak-priced `models` (weekly `blocked` windows) and its `flatModels` (vendor-documented all-hours pricing, always OFF_PEAK and never held); `models` wins if a pattern matches both. A configured provider with an unmatched model is `UNKNOWN` and blocked. Invalid timezone or schedule data is blocked. Providers without an installed policy are `UNTRACKED`; they are not covered by this plugin until a policy is added. Queue tasks must match a valid policy. The built-in `deepseek-opencode-go` policy is defined once in `src/policy.ts`, in UTC, with the Chinese statutory holiday calendar in `src/holidays.ts`. Time evaluation uses `Intl.DateTimeFormat` and the policy's IANA timezone, including weekday changes and DST; holiday dates are evaluated on the calendar's own timezone (`Asia/Shanghai`) so the host-local timezone never matters. The user's display timezone does not change pricing boundaries. Full-day off-peak exception dates override every weekday peak window, and `nextTransition` skips holiday peak windows. diff --git a/CHANGELOG.md b/CHANGELOG.md index cdc5aec..834a18d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.1.5 - 2026-10-15 + +- Automatic chat titles work again. OpenCode prepares automatic naming as a separate `title` model request that uses the provider's *small* model — for `opencode-go` that is `gpt-6-luna` — while the built-in policy only listed the DeepSeek models, so every title request was an `UNKNOWN` configured-provider model and failed closed at all hours. Titles stopped being generated when the plugin was installed, independent of peak pricing. The policy schema now supports `flatModels`: explicit, vendor-documented patterns for models with a single all-hours price (the Go pricing table lists peak/off-peak tiers only for the DeepSeek models). Flat models are `OFF_PEAK` at every hour, are never held or blocked, and cannot create peak-priced traffic; `models` takes precedence if a pattern matches both, and any unmatched model still fails closed. The built-in `deepseek-opencode-go` policy declares the provider's documented flat models, and an unmodified stored copy of the built-in policy adopts that list at read time. +- A held `title` request now persists a semantic `pending-title` recovery instead of being lost with the in-memory hold. A session can hold a primary continuation and a pending-title marker at the same time (v0.1.4 kept only one recovery entry per session, so a held title could shadow a primary deferral). At the allowed window the marker is delivered as a check, never a replay: a session that already has a title — generated by OpenCode or renamed by the user — clears the marker without any request, and a session that still needs one is left to OpenCode's own generation, which every interruption path (inactivity, supersede, shutdown, reload) re-triggers through the same input promotion that continues the session. `/offpeak status` reports pending markers as `titleRecoveryWaiting`. +- Recovery never mutates a session's title. OpenCode 2.0.27 exposes no plugin-facing regeneration call, and the plugin host's `session.update({ title: "" })` is not the HTTP API's regeneration — it renames the session to an empty title. The pending-title delivery therefore sends nothing of its own; a manual rename or a title generated by another process is never overwritten, and a title is never generated twice. +- The transport guard now receives the request kind and removes only the recovery class that belongs to the request that is actually crossing: a released `title` request clears a pending title, a released primary request clears a primary continuation, and neither can clear the other. +- Added deterministic regression coverage for flat-model pricing (allowed at every hour including peak windows, DeepSeek windows unchanged, overlap precedence, ambiguous and invalid lists failing closed), the pending-title marker (persistence across reload, manual-rename and already-titled safety, exact creation-time fallback still meaning untitled, child and unknown sessions, user cancel versus inactivity, at-most-once semantics across multiple reloads, coexistence with a primary deferral), and read-time adoption of the flat model list by an unmodified stored policy. + ## 0.1.4 - 2026-10-14 - Peak waiting moved before the provider attempt. A normal interactive request (and a queue-owned auto task) that reaches peak pricing is now deferred at OpenCode v2's `model.request` hook, which runs while the request is being prepared and before the provider attempt (and its response-header timeout) exists. diff --git a/DECISIONS.md b/DECISIONS.md index feb38a3..737e44a 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -68,7 +68,15 @@ OpenCode 2.0.27 begins an attempt's response-header timeout (default 300s) at at The DeepSeek pricing documentation defines the peak windows and the exemption: "Peak hours are 01:00 - 04:00 and 06:00 - 10:00 UTC, Monday through Friday, excluding Chinese public holidays. All other hours are off-peak, including weekends and Chinese public holidays in full." The OpenCode Go pricing page documents the same weekly windows for this provider and links to the DeepSeek page for the definition, and this pass was explicitly instructed to implement the confirmed holiday rule as part of the canonical policy. The policy model therefore supports explicit full-day OFF_PEAK exception calendars (`holidays`) that override every weekday peak window, and the built-in policy carries the 2026 China calendar from the State Council notice (国办发明电〔2025〕7号, published 2025-11-04) in `src/holidays.ts` with year, dates, source URL, verification date, and description. Holiday dates are evaluated on the calendar's own time zone (`Asia/Shanghai`), independent of the host and the policy display time zone. Adjusted weekend make-up workdays stay OFF_PEAK because weekends are always off-peak. `nextTransition` scans up to 14 days so long holiday runs cannot hide the next real transition, and a year without a calendar keeps the deterministic weekly schedule rather than guessing. Annual maintenance is required: the State Council publishes the next year's arrangement each November. Upstream [anomalyco/opencode#53071](https://github.com/anomalyco/opencode/issues/53071) (open, no vendor reply) reports peak-rate billing for Go during the 2026 National Day holiday; the October 1–7 Go billing comparison is not exposed per request (the provider offers only aggregate usage percentages), so that report is the cue for the annual re-check rather than a blocker for this confirmed rule. -A stored `policies` array that is an unmodified copy of the built-in policy (same id, providers, models, time zone, multiplier, and weekly windows) adopts the built-in holiday calendar when the config is read. Existing installations commonly persist such a copy after their first `/offpeak on|off`, so they gain yearly holiday updates without editing the file, while any customized policy — including an explicit `holidays` list — is preserved exactly. +A stored `policies` array that is an unmodified copy of the built-in policy (same id, providers, models, time zone, multiplier, and weekly windows) adopts the built-in holiday calendar *and the built-in flat model list* when the config is read. Existing installations commonly persist such a copy after their first `/offpeak on|off`, so they gain yearly holiday updates and the flat model list without editing the file, while any customized policy — including an explicit `holidays` or `flatModels` list — is preserved exactly. + +## 2026-10-15: Auxiliary title requests persist a pending-title marker; recovery never mutates the session + +Automatic chat naming is a separate `title` model request. Like every model request it is priced and peak-protected, but OpenCode generates it only once, when the first input of an untitled root session is promoted, and it is not retried by OpenCode itself. A held title therefore persists a small `pending-title` recovery entry (same minimal metadata as a deferral, with `origin: "pending-title"`), and a session can hold a primary continuation and a title marker at the same time. Recovery at the allowed window is a semantic check, never a replay or mutation: a session that already has a title (generated, concurrent, or manually renamed) clears the marker without any request, and a session that still needs one is left to OpenCode's own generation, which every interruption path re-triggers through the same input promotion that continues the session. The marker waits for the title and clears when it appears; a stale marker is dropped with a diagnostic after the settle cap. Evidence: in real OpenCode 2.0.27 fixture runs, a PEAK hold plus a full server restart produced exactly one native title request through the recovery continuation's promotion, and the plugin's own attempted `context.session.update({ title: "" })` was proven unsafe — the plugin host renames the session to an empty title, unlike the HTTP handler's regenerate semantic — so the delivery deliberately mutates nothing and the driver exposes no such call. + +## 2026-10-15: Vendor-documented flat-priced models are declared in the policy + +OpenCode names sessions with the provider's small model (`model.small`), which for `opencode-go` is `gpt-6-luna`. The built-in policy only listed the DeepSeek models, so every title request was an `UNKNOWN` configured-provider model and failed closed at all hours — automatic titles stopped working when the plugin went live, independent of PEAK. The Go pricing table lists peak/off-peak tiers only for the DeepSeek models; every other documented Go model has one all-hours price. The policy schema therefore supports `flatModels` (explicit globs, `models` takes precedence, everything unmatched still fails closed) and the built-in policy declares the provider's documented flat models. This cannot create peak-priced traffic: it only marks models the vendor prices identically at every hour, and an unlisted future model stays `UNKNOWN` and blocked. Evidence: a live-shaped fixture run (provider `opencode-go`, models `deepseek-v4.1-flash` + `gpt-6-luna`, the stored pre-v0.1.5 policy copy) lost titles under v0.1.4 and generated them under v0.1.5 through the read-time adoption path. ## 2026-10-13: Peak-aware retry recovery uses OpenCode's retry hook diff --git a/README.md b/README.md index eae427c..01891bf 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,10 @@ Queue OpenCode agent tasks, block paid model requests during expensive pricing w ## Install ```sh -opencode plugin add git+https://github.com/lutzkind/opencode-offpeak.git#v0.1.4 +opencode plugin add git+https://github.com/lutzkind/opencode-offpeak.git#v0.1.5 ``` -This installs the released `v0.1.4` directly from its GitHub tag using [OpenCode v2's supported npm-compatible Git package syntax](https://opencode.ai/v2/docs/plugins). The release includes both server and TUI entrypoints. npm publication is optional future distribution; it is not required to install or use this release. +This installs the released `v0.1.5` directly from its GitHub tag using [OpenCode v2's supported npm-compatible Git package syntax](https://opencode.ai/v2/docs/plugins). The release includes both server and TUI entrypoints. npm publication is optional future distribution; it is not required to install or use this release. Restart OpenCode after installation. The plugin defaults to enabled, uses the current system timezone for display, and enforces the built-in DeepSeek/OpenCode Go policy without extra setup. @@ -103,9 +103,9 @@ Holiday dates come from the official State Council calendar kept in `src/holiday The direct DeepSeek pricing documentation defines the exemption ("…excluding Chinese public holidays. All other hours are off-peak, including weekends and Chinese public holidays in full."), and the OpenCode Go pricing page documents the same weekly windows for this provider. The Go price table lists peak DeepSeek rates at twice their off-peak per-token rates, so equal token use consumes the allowance twice as fast during peak hours. Re-verify both pages annually; upstream [anomalyco/opencode#53071](https://github.com/anomalyco/opencode/issues/53071) tracks an open user report about National Day holiday billing and is the cue for a fresh check. -The built-in model IDs are `deepseek-v4.1-flash`, `deepseek-v4-pro`, `deepseek-v4-flash`, and `deepseek-v4-flash-vision-exp` under provider ID `opencode-go`. An unrecognized model under a configured provider fails closed. A provider with no policy is shown as `UNTRACKED`; queued tasks require a matching policy. +The built-in model IDs are `deepseek-v4.1-flash`, `deepseek-v4-pro`, `deepseek-v4-flash`, and `deepseek-v4-flash-vision-exp` under provider ID `opencode-go`. Every other Go model documented in the pricing table has a single all-hours price and is declared as a flat model: it is never peak-priced, so it is never held or blocked. That list mirrors the provider's own table, because OpenCode names automatic session titles with the provider's small model — for `opencode-go` that is `gpt-6-luna` — and a title must not be blocked as an unknown model. A model that matches neither list under a configured provider fails closed; a provider with no policy is shown as `UNTRACKED`; queued tasks require a matching policy. -Pricing details can change. This release verified the built-in schedule and the 2026 holiday calendar against the [OpenCode Go pricing page](https://opencode.ai/docs/go/), the [DeepSeek pricing documentation](https://api-docs.deepseek.com/quick_start/pricing), and the [State Council notice](https://www.gov.cn/gongbao/2025/issue_12406/202511/content_7048922.html) on 2026-10-14. Review those sources before relying on a later policy update. +Pricing details can change. This release verified the built-in schedule, the flat model list, and the 2026 holiday calendar against the [OpenCode Go pricing page](https://opencode.ai/docs/go/), the [DeepSeek pricing documentation](https://api-docs.deepseek.com/quick_start/pricing), and the [State Council notice](https://www.gov.cn/gongbao/2025/issue_12406/202511/content_7048922.html) on 2026-10-15. Review those sources before relying on a later policy update. ## Configuration diff --git a/RELEASING.md b/RELEASING.md index cee28b8..acafad0 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -2,13 +2,13 @@ ## Current public distribution -`v0.1.4` is complete and publicly installable from its exact GitHub tag with OpenCode CLI v2: +`v0.1.5` is complete and publicly installable from its exact GitHub tag with OpenCode CLI v2: ```sh -opencode plugin add git+https://github.com/lutzkind/opencode-offpeak.git#v0.1.4 +opencode plugin add git+https://github.com/lutzkind/opencode-offpeak.git#v0.1.5 ``` -`v0.1.0`, `v0.1.1`, `v0.1.2`, and `v0.1.3` remain available at their own tags, but `v0.1.4` is the canonical release: it moves peak waiting to OpenCode's pre-model lifecycle hook (`model.request`), so a request submitted during peak no longer holds a provider attempt open or surfaces `Timed out waiting for response headers`, and the same session continues the same turn at the boundary; it implements the Chinese statutory public holidays as full-day OFF_PEAK via explicit holiday exception calendars with the official 2026 State Council calendar; the transport guard remains the final hard barrier, the v0.1.3 retry recovery stays for requests that fail after crossing into peak, and origin-aware interrupt handling keeps deferred sessions recoverable across reloads. This sits on top of the v0.1.2 interactive boundary hold, the v0.1.1 compatibility enforcement, the documented `Plugin.define(...)` TUI shape, `/offpeak remove`, and the real-TUI regression smoke test. OpenCode officially accepts npm-compatible Git package specifications, including GitHub URLs and exact tags ([plugin installation docs](https://opencode.ai/v2/docs/plugins)). This install path loads both the server and TUI entrypoints. npm publication is optional future distribution; npm login, 2FA, or registry publication is not required before this release can be installed or used. +`v0.1.0` through `v0.1.4` remain available at their own tags, but `v0.1.5` is the canonical release: it restores automatic chat titles (OpenCode names sessions with the provider's small model, `opencode-go/gpt-6-luna`, which the built-in policy now declares as a vendor-documented flat-priced model instead of failing it closed as an unknown configured-provider model) and persists a semantic `pending-title` recovery for a title request that is held for peak pricing, so a shared session can carry both a primary continuation and a title marker and the recovery never overwrites or regenerates a title. This sits on top of the v0.1.4 pre-model peak deferral and Chinese holiday policy, the v0.1.3 retry recovery, the v0.1.2 interactive boundary hold, the v0.1.1 compatibility enforcement, the documented `Plugin.define(...)` TUI shape, and the real-TUI regression smoke test. OpenCode officially accepts npm-compatible Git package specifications, including GitHub URLs and exact tags ([plugin installation docs](https://opencode.ai/v2/docs/plugins)). This install path loads both the server and TUI entrypoints. npm publication is optional future distribution; npm login, 2FA, or registry publication is not required before this release can be installed or used. ## Optional npm distribution diff --git a/docs/pricing-policies.md b/docs/pricing-policies.md index 2f6adc8..a33d477 100644 --- a/docs/pricing-policies.md +++ b/docs/pricing-policies.md @@ -7,6 +7,7 @@ "id": "example-provider", "providers": ["provider-id"], "models": ["model-id", "model-family-*"], + "flatModels": ["flat-model-id", "flat-family-*"], "timeZone": "Europe/Berlin", "blocked": [ { "days": [1, 2, 3, 4, 5], "start": "09:00", "end": "17:00" } @@ -19,13 +20,17 @@ Weekdays are `0` Sunday through `6` Saturday. Windows use local policy time and are start-inclusive/end-exclusive. Windows may cross midnight; their weekday applies to the day on which the window starts. Model patterns support `*` as a wildcard. Do not create ambiguous patterns that match the same provider/model. +`models` are the peak-priced models: they are `PEAK` inside a blocked window and `OFF_PEAK` outside it. `flatModels` are the provider's models with a single all-hours price documented by the provider: they are `OFF_PEAK` at every hour and are never held or blocked. A pattern that matches both lists is treated as peak-priced (`models` wins). A model that matches neither list is `UNKNOWN` and fails closed. Both lists come from the provider's own pricing page; cite it in `source` and record the date in `verifiedAt`. + The policy includes a source URL and verification date to make schedule provenance reviewable. `multiplier` is status metadata; the guard does not estimate or aggregate bills. ## Built-in policy `deepseek-opencode-go` matches provider ID `opencode-go` and the DeepSeek model IDs listed in the [README](../README.md). Its canonical schedule is defined only in `src/policy.ts`, with the holiday calendar in `src/holidays.ts`. -The rules are: peak Monday–Friday 01:00–04:00 and 06:00–10:00 UTC; off-peak all other hours, weekends, and Chinese statutory public holidays in full; weekend make-up working days stay off-peak because weekends are always off-peak. +The rules are: peak Monday–Friday 01:00–04:00 and 06:00–10:00 UTC for the DeepSeek models; off-peak all other hours, weekends, and Chinese statutory public holidays in full; weekend make-up working days stay off-peak because weekends are always off-peak. + +The [Go pricing table](https://opencode.ai/docs/go/) lists peak/off-peak tiers only for the DeepSeek models. Every other documented Go model has one all-hours price, so the built-in `flatModels` list mirrors that table (GPT 6/5.6 Luna, Claude Haiku 5.5, Gemini 4 Argon Contributor, GLM, Kimi, LongCat, MiMo, MiniMax, Muse Spark, Qwen, Hy, Space Bunny, Step 5 Preview Free, Cat Jam Free, Grok). This matters in practice because OpenCode generates automatic session titles with the provider's *small* model (`model.small`), which for `opencode-go` is `gpt-6-luna`; without the flat entry that auxiliary request would be `UNKNOWN` and a title could never be generated. A stored copy of the built-in policy that predates `flatModels` adopts the built-in list at read time when it is otherwise unmodified. Holiday support uses explicit full-day OFF_PEAK exception calendars on the policy: @@ -40,7 +45,7 @@ Holiday support uses explicit full-day OFF_PEAK exception calendars on the polic } ``` -A listed date overrides every weekday peak window for the whole calendar day in the calendar's own time zone. The built-in 2026 calendar comes from the official State Council notice; `nextTransition` skips holiday peak windows; a year without a calendar keeps the deterministic weekly schedule, and no future dates are guessed. The State Council publishes the next year's arrangement each November, so the calendar needs one data update per year. The rule follows the DeepSeek pricing documentation referenced by the OpenCode Go pricing page; upstream [anomalyco/opencode#53071](https://github.com/anomalyco/opencode/issues/53071) tracks an open report about National Day billing and is the cue for the annual re-check. +A listed date overrides every weekday peak window for the whole calendar day in the calendar's own time zone. The built-in 2026 calendar comes from the official State Council notice; `nextTransition` skips holiday peak windows; a year without a calendar keeps the deterministic weekly schedule, and no future dates are guessed. The State Council publishes the next year's arrangement each November, so the calendar needs one data update per year. The rule follows the DeepSeek pricing documentation referenced by the OpenCode Go pricing page; upstream [anomalyco/opencode#53071](https://github.com/anomalyco/opencode/issues/53071) tracks an open report about National Day billing and is the cue for the annual re-check. The annual review also re-checks the `flatModels` list against the Go pricing table: a newly added model that is not listed becomes `UNKNOWN` and fails closed until the policy is updated. An unknown model under provider `opencode-go` returns `UNKNOWN` and fails closed. Add a verified model/policy entry before relying on newly added provider models. diff --git a/docs/releases/v0.1.5.md b/docs/releases/v0.1.5.md new file mode 100644 index 0000000..28a6a5c --- /dev/null +++ b/docs/releases/v0.1.5.md @@ -0,0 +1,42 @@ +# opencode-offpeak v0.1.5 + +Automatic chat titles are restored, and a title request that is held for peak pricing can no longer be lost. + +## Why titles stopped working + +OpenCode prepares automatic session naming as its own `title` model request. That request does not use the session model: it uses the provider's *small* model, and for `opencode-go` that is `gpt-6-luna`. The built-in policy only listed the DeepSeek models, so every title request evaluated as `UNKNOWN` for a configured provider and failed closed — at every hour, not only during peak. The result was a successful session with no automatic name, from the moment the plugin was installed. + +The Go pricing table lists Off-Peak/Peak tiers only for the DeepSeek models. Every other documented Go model has a single all-hours price. + +## What changed + +- **`flatModels` in the policy schema.** Each policy can declare the provider's documented all-hours-priced models (explicit globs). They are `OFF_PEAK` at every hour and are never held or blocked. `models` keeps precedence if a pattern matches both, and any model that matches neither list still fails closed — an unlisted future model cannot silently gain peak traffic. The built-in `deepseek-opencode-go` policy lists the documented Go flat models, and an unmodified stored copy of the built-in policy adopts the list at read time, so existing installations pick it up without editing `offpeak.json`. +- **Pending-title recovery.** A held `title` request persists a small `pending-title` marker (session ID, provider/model, location, timestamp, origin) instead of disappearing with the in-memory hold. A session can hold a primary continuation and a title marker at the same time; v0.1.4 stored only one recovery entry per session, so a held title could shadow a primary deferral. +- **Recovery is a check, not a replay and not a mutation.** At the allowed window, a session that already has a title — generated by OpenCode, generated concurrently, or renamed by the user — clears the marker without any request. A session that still needs a title is left to OpenCode's own generation: every path that loses a title hold (inactivity, supersede, shutdown, reload) also resumes the session, and that input promotion is OpenCode's native naming trigger. The marker waits for the title and clears when it appears. +- **No title is ever overwritten or generated twice.** The plugin never calls `session.update` for titles. OpenCode 2.0.27's plugin host renames the session to an *empty* title for `{ title: "" }` (unlike the HTTP handler, which regenerates), and this pass proved that live in a fixture run; the delivery therefore mutates nothing. +- **Kind-aware transport cleanup.** The `http.request`/WebSocket guard now sees the request kind and removes only its own recovery class: a released title clears a pending title, a released primary clears a primary continuation, and neither can clear the other. + +## What is unchanged + +- Peak protection: a peak-priced request still never crosses the provider transport during a blocked window, `UNKNOWN` still fails closed for every request kind, and `OFF`/`queue`/`guard`/`run-now` semantics are untouched. +- The v0.1.4 pre-model deferral, the v0.1.3 cross-boundary retry recovery, the persistence of primary deferrals across reload/restart, and the Chinese public-holiday calendar. + +## Evidence + +- Unit suite: 86 tests (`npm run lint`, `npm run typecheck`, `npm test`), including flat-model pricing at peak hours, overlap precedence, ambiguous/invalid lists failing closed, pending-title persistence and cleanup semantics, and read-time policy adoption. +- Real OpenCode 2.0.27 + local fixture provider (no paid traffic): + - live-shaped config under v0.1.4: title request for `opencode-go/gpt-6-luna` blocked, session left untitled, one provider request total; + - the same config under v0.1.5: the flat list is adopted from the stored copy, the title request is allowed, one title generated; + - PEAK window with a covered title model: zero provider traffic while blocked, `pending-title` + `deferred` markers persisted together, both released at the boundary, one title and one primary request; + - PEAK + `opencode reload`: markers persisted, one title and one primary request at the boundary, no duplicate prompt; + - PEAK + full server restart: markers persisted, exactly one continuation and exactly one native title request after the boundary, one session, recovery state clean; + - uncovered title model: still fails closed with zero provider traffic. +- Installed live path: `opencode plugin add git+https://github.com/lutzkind/opencode-offpeak.git#v0.1.5`, `/offpeak status` ON/`auto`, queue and recovery clean. + +## Installation + +```sh +opencode plugin add git+https://github.com/lutzkind/opencode-offpeak.git#v0.1.5 +``` + +Requires OpenCode CLI v2 `>=2.0.26 <2.1.0` (validated on 2.0.27). diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index fe10b80..592330e 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2,7 +2,7 @@ ## `/offpeak` is not recognized -Confirm the installed CLI is within `>=2.0.26 <2.1.0`, install with `opencode plugin add git+https://github.com/lutzkind/opencode-offpeak.git#v0.1.4`, then restart OpenCode. Check `opencode plugin list` and the OpenCode server logs. The server entrypoint provides RPC while the TUI entrypoint registers the slash command. +Confirm the installed CLI is within `>=2.0.26 <2.1.0`, install with `opencode plugin add git+https://github.com/lutzkind/opencode-offpeak.git#v0.1.5`, then restart OpenCode. Check `opencode plugin list` and the OpenCode server logs. The server entrypoint provides RPC while the TUI entrypoint registers the slash command. ## `Keymap.Provider is missing` in the TUI diff --git a/package.json b/package.json index 8713212..d68d938 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-offpeak", - "version": "0.1.4", + "version": "0.1.5", "description": "Queue OpenCode agent tasks and guard model requests against expensive pricing windows.", "author": "Lutz Kind", "license": "MIT", diff --git a/src/config.ts b/src/config.ts index 620c9b6..6f16420 100644 --- a/src/config.ts +++ b/src/config.ts @@ -85,23 +85,32 @@ export function validateConfig(value: unknown, fallback: OffpeakConfig): Offpeak enabled: value.enabled, mode: value.mode, displayTimeZone, - policies: structuredClone(adoptBuiltinHolidays(configuredPolicies, fallback.policies)), + policies: structuredClone(adoptBuiltinDefaults(configuredPolicies, fallback.policies)), }; } /** * A stored policies array that is an unmodified copy of a built-in policy - * adopts the built-in full-day OFF_PEAK calendars (for example the yearly - * Chinese holiday list) without touching any customized schedule. A policy - * that differs in provider, model, time zone, multiplier, or weekly windows is - * preserved exactly as stored, and an explicit holiday list always wins. + * adopts the built-in vendor data (the yearly full-day OFF_PEAK holiday + * calendars, and the documented flat all-hours model list) without touching + * any customized schedule. A policy that differs in provider, model, time + * zone, multiplier, or weekly windows is preserved exactly as stored, and an + * explicit holiday list or flat model list always wins. */ -function adoptBuiltinHolidays(stored: PricingPolicy[], builtin: PricingPolicy[]): PricingPolicy[] { +function adoptBuiltinDefaults(stored: PricingPolicy[], builtin: PricingPolicy[]): PricingPolicy[] { return stored.map((policy) => { const reference = builtin.find((candidate) => candidate.id === policy.id); - if (!reference?.holidays?.length || policy.holidays !== undefined) return policy; + if (!reference) return policy; if (!sameWeeklySchedule(policy, reference)) return policy; - return { ...policy, holidays: structuredClone(reference.holidays) }; + return { + ...policy, + ...(policy.holidays === undefined && reference.holidays?.length + ? { holidays: structuredClone(reference.holidays) } + : {}), + ...(policy.flatModels === undefined && reference.flatModels?.length + ? { flatModels: structuredClone(reference.flatModels) } + : {}), + }; }); } diff --git a/src/engine.ts b/src/engine.ts index 5e45007..6238dfa 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -8,16 +8,27 @@ import { type OffpeakTask, type QueueRepository, type RecoveryEntry, + type RecoveryOrigin, type RecoveryRepository, type TaskModel, type TaskState, } from "./store.js"; +export interface SessionSnapshot { + outcome?: "succeeded" | "failed" | "interrupted"; + /** Session title as stored, when the session still exists. */ + title?: string; + /** Set for child (subagent) sessions; they never receive automatic titles. */ + parentID?: string; + /** Session creation time in epoch milliseconds. */ + created?: number; +} + export interface SessionDriver { create(input: { cwd: string; model: TaskModel; taskID: string }): Promise; prompt(sessionID: string, text: string): Promise; wait(sessionID: string): Promise; - get(sessionID: string): Promise<{ outcome?: "succeeded" | "failed" | "interrupted" }>; + get(sessionID: string): Promise; interrupt(sessionID: string): Promise; } @@ -53,6 +64,8 @@ export interface EngineStatus { interactiveWaiting: number; /** Interactive sessions waiting for an off-peak recovery continuation. */ recoveryWaiting: number; + /** Subset of recoveryWaiting: sessions whose automatic title is still pending. */ + titleRecoveryWaiting: number; failed: number; tasks: OffpeakTask[]; configPath: string; @@ -87,11 +100,34 @@ const RETRYABLE_ERROR_TYPES = new Set([ const RECOVERY_CONTINUATION = "Continue the task in this session from the current project state. Avoid repeating work that is already complete."; +/** + * Mirrors OpenCode's own `SessionTitle.isExactRootFallback` for a root session: + * a session needs an automatic title when it has none, or when its stored title + * is exactly the creation-time fallback (`New session - `). Any + * other title was generated already or set by the user, and must never be + * overwritten. Child sessions are excluded because OpenCode never titles them + * automatically. + */ +export function needsAutoTitle(session: SessionSnapshot): boolean { + if (session.parentID) return false; + if (session.title === undefined) return true; + if (session.created === undefined) return false; + return session.title === `New session - ${new Date(session.created).toISOString()}`; +} + /** How long to wait before re-checking a session that has not settled yet. */ const RECOVERY_SETTLE_DELAY_MS = 2_000; /** Give up (without sending anything) if a session never settles. */ const RECOVERY_SETTLE_CAP_MS = 5 * 60_000; +/** + * Recovery origins that continue the primary turn. `undefined` covers entries + * persisted by older plugin versions. + */ +const PRIMARY_RECOVERY_ORIGINS = [undefined, "retry-failure", "deferred"] as const; +/** The semantic "this session still needs its automatic title" marker. */ +const TITLE_RECOVERY_ORIGIN = "pending-title" as const; + export interface RetryFailureInput { sessionID: string; providerID: string; @@ -142,8 +178,14 @@ export class OffpeakEngine { readonly #holdAborts = new Map(); readonly #recoveriesRepo: RecoveryRepository | undefined; readonly #location: string; + /** + * Recovery entries keyed by `${origin}:${sessionID}`. A session can hold a + * primary continuation and a pending-title marker at the same time; they + * are removed and delivered independently. + */ readonly #recoveries = new Map(); readonly #recoveryChecks = new Map(); + readonly #titleChecks = new Map(); #nextInteractiveWaiter = 0; #timer: NodeJS.Timeout | undefined; #recoveryTimer: NodeJS.Timeout | undefined; @@ -381,6 +423,9 @@ export class OffpeakEngine { waiting: tasks.filter((task) => task.state === "WAITING_OFFPEAK").length, interactiveWaiting: this.#interactiveWaiters.size, recoveryWaiting: this.#recoveries.size, + titleRecoveryWaiting: [...this.#recoveries.values()].filter( + (entry) => entry.origin === TITLE_RECOVERY_ORIGIN, + ).length, failed: tasks.filter((task) => task.state === "FAILED").length, tasks, configPath: settings.path, @@ -389,9 +434,14 @@ export class OffpeakEngine { } async guardRequest(input: GuardInput): Promise { - // Any fresh request for this session supersedes a pending recovery continuation. - await this.#removeRecovery(input.sessionID); - await this.#guardPricing(input, false); + // Any fresh request for this session supersedes a pending continuation of + // the same class. A title request only supersedes a pending title; it must + // not clear a primary deferral that another held request still owns, and a + // primary request must not clear a title that is still being held. + if (input.kind === "title") + await this.#removeRecovery(input.sessionID, [TITLE_RECOVERY_ORIGIN]); + else await this.#removeRecovery(input.sessionID, PRIMARY_RECOVERY_ORIGINS); + await this.#guardPricing(input, undefined); } /** @@ -400,19 +450,33 @@ export class OffpeakEngine { * OpenCode's response-header timeout clock) exists, so holding for OFF_PEAK * here never leaves a provider attempt open. * - * A held primary request of an interactive session is persisted as a - * deferral: if the plugin reloads or the server restarts while it waits, the - * existing recovery flush delivers one same-session continuation once the - * allowed window arrives. The release itself removes the deferral, so normal - * completion never sends a continuation. + * A held primary request persists a deferral: if the plugin reloads or the + * server restarts while it waits, the existing recovery flush delivers one + * same-session continuation once the allowed window arrives. The release + * itself removes the deferral, so normal completion never sends a + * continuation. + * + * A held Automatic title request (kind `title`) persists a semantic + * `pending-title` marker instead of a continuation: recovery re-runs + * OpenCode's own title generation for a session that still needs a title, + * and it is cleared without any request when the session was titled (or + * manually renamed) while it waited. */ async guardModelRequest(input: GuardInput): Promise { - const primary = input.kind === "primary"; - if (primary) await this.#removeRecovery(input.sessionID); - await this.#guardPricing(input, primary); + const origin: RecoveryOrigin | undefined = + input.kind === "primary" + ? "deferred" + : input.kind === "title" + ? TITLE_RECOVERY_ORIGIN + : undefined; + if (origin !== undefined) await this.#removeRecovery(input.sessionID, [origin]); + await this.#guardPricing(input, origin); } - async #guardPricing(input: GuardInput, persistDeferral: boolean): Promise { + async #guardPricing( + input: GuardInput, + deferralOrigin: RecoveryOrigin | undefined, + ): Promise { const task = await this.#taskForSession(input.sessionID); const first = await this.#config.read(); if (!first.config) @@ -432,14 +496,14 @@ export class OffpeakEngine { if (task && first.config.mode === "auto") { await this.#transition(task.id, "WAITING_OFFPEAK"); - await this.#holdUntilAllowed(input, task, false); + await this.#holdUntilAllowed(input, task, undefined); return; } // A normal interactive request in auto mode is held at the boundary instead of // being discarded; the same request and session continue when pricing allows. if (!task && first.config.enabled && first.config.mode === "auto") { - await this.#holdUntilAllowed(input, undefined, persistDeferral); + await this.#holdUntilAllowed(input, undefined, deferralOrigin); return; } @@ -465,21 +529,26 @@ export class OffpeakEngine { * may use their explicit override; interactive requests are tracked in memory * (for status) and fail closed on shutdown instead of guessing the price state. * - * `persistDeferral` marks an interactive primary request that is safe to resume - * after a plugin reload or server restart: the recovery entry is persisted while - * the request waits and removed again when the wait ends normally. A shutdown or - * guard error leaves it in place so the continuation can be delivered once the + * `deferralOrigin` marks an interactive request that is safe to resume after a + * plugin reload or server restart: the recovery entry is persisted while the + * request waits and removed again when the wait ends normally. A shutdown or + * guard error leaves it in place so recovery can be delivered once the * allowed window arrives. */ async #holdUntilAllowed( input: GuardInput, task?: OffpeakTask, - persistDeferral = false, + deferralOrigin?: RecoveryOrigin, ): Promise { const waiter = task ? undefined : this.#registerInteractiveWaiter(); - const deferred = persistDeferral && !task; + const deferred = deferralOrigin !== undefined && !task; if (deferred) { - await this.#registerRecovery(input.sessionID, input.providerID, input.modelID, "deferred"); + await this.#registerRecovery( + input.sessionID, + input.providerID, + input.modelID, + deferralOrigin, + ); this.#requestPump(); } const abortEpoch = this.#holdAborts.get(input.sessionID) ?? 0; @@ -514,7 +583,7 @@ export class OffpeakEngine { } if (settings.config.enabled && decision.allowed) { if (task) await this.#transition(task.id, "RUNNING"); - if (deferred) await this.#removeRecovery(input.sessionID); + if (deferred) await this.#removeRecovery(input.sessionID, [deferralOrigin]); return; } if (settings.config.enabled && decision.nextTransition === undefined) { @@ -594,8 +663,10 @@ export class OffpeakEngine { async noteExecutionInterrupted(input: { sessionID: string; reason: string }): Promise { if (this.#disposed) return; if (input.reason === "user") { - const entry = this.#recoveries.get(input.sessionID); - if (entry?.origin === "deferred") await this.#removeRecovery(input.sessionID); + // The user cancelled the work. Neither the same-session continuation nor + // a pending automatic title is delivered for abandoned work; OpenCode + // itself also generates no title without new input. + await this.#removeRecovery(input.sessionID, ["deferred", TITLE_RECOVERY_ORIGIN]); } if ( input.reason !== "user" && @@ -616,31 +687,49 @@ export class OffpeakEngine { const document = await repo.load(); for (const entry of document.entries) { if (entry.directory !== this.#location) continue; - this.#recoveries.set(entry.sessionID, entry); + this.#recoveries.set(this.#recoveryKey(entry), entry); } } + #recoveryKey(entry: Pick): string { + return `${entry.origin ?? "legacy"}\u0000${entry.sessionID}`; + } + async #registerRecovery( sessionID: string, providerID: string, modelID: string, - origin: "retry-failure" | "deferred", + origin: RecoveryOrigin, ): Promise { - if (this.#recoveries.has(sessionID)) return; - this.#recoveries.set(sessionID, { + const entry: RecoveryEntry = { sessionID, providerID, modelID, directory: this.#location, heldAt: new Date(this.#clock.now()).toISOString(), origin, - }); + }; + const key = this.#recoveryKey(entry); + if (this.#recoveries.has(key)) return; + this.#recoveries.set(key, entry); await this.#persistRecoveries(); } - async #removeRecovery(sessionID: string): Promise { + async #removeRecovery( + sessionID: string, + origins: ReadonlyArray, + ): Promise { + let changed = false; + for (const origin of origins) { + const key = + origin === undefined + ? `legacy\u0000${sessionID}` + : this.#recoveryKey({ sessionID, origin }); + if (this.#recoveries.delete(key)) changed = true; + } + if (!changed) return; this.#recoveryChecks.delete(sessionID); - if (!this.#recoveries.delete(sessionID)) return; + this.#titleChecks.delete(sessionID); await this.#persistRecoveries(); } @@ -658,9 +747,14 @@ export class OffpeakEngine { } /** - * Sends recovery continuations for sessions whose allowed window has arrived. - * Called from the pump, so the existing signal and boundary timer drive it; - * there is no polling loop. + * Delivers recoveries whose allowed window has arrived. Called from the pump, + * so the existing signal and boundary timer drive it; there is no polling + * loop. + * + * Primary continuations are delivered first so a same-session continuation + * makes the session busy again before any pending title is considered: the + * continuation's input promotion triggers OpenCode's own title generation, + * and the title marker is then cleared without sending anything. */ async #flushRecoveries(): Promise { if (this.#disposed) return; @@ -671,8 +765,11 @@ export class OffpeakEngine { const settings = await this.#config.read(); if (!settings.config) return; let next: number | undefined; - for (const entry of [...this.#recoveries.values()]) { - if (entry.directory !== this.#location) continue; + const entries = [...this.#recoveries.values()].filter( + (entry) => entry.directory === this.#location, + ); + for (const entry of entries) { + if (entry.origin === TITLE_RECOVERY_ORIGIN) continue; const decision = evaluatePricing( settings.config.policies, entry.providerID, @@ -689,37 +786,55 @@ export class OffpeakEngine { next === undefined ? decision.nextTransition : Math.min(next, decision.nextTransition); } } + for (const entry of entries) { + if (entry.origin !== TITLE_RECOVERY_ORIGIN) continue; + const decision = evaluatePricing( + settings.config.policies, + entry.providerID, + entry.modelID, + this.#clock.now(), + ); + if (settings.config.enabled && decision.allowed) { + const retryAt = await this.#deliverTitleRecovery(entry); + if (retryAt !== undefined) next = next === undefined ? retryAt : Math.min(next, retryAt); + continue; + } + if (settings.config.enabled && decision.nextTransition !== undefined) { + next = + next === undefined ? decision.nextTransition : Math.min(next, decision.nextTransition); + } + } this.#scheduleRecovery(next); } /** Returns a timestamp to retry at when the session has not settled yet. */ async #deliverRecovery(entry: RecoveryEntry): Promise { - let outcome: string | undefined; + let session: SessionSnapshot; try { - outcome = (await this.#sessions.get(entry.sessionID)).outcome; + session = await this.#sessions.get(entry.sessionID); } catch (error) { this.#onError(error); - await this.#removeRecovery(entry.sessionID); + await this.#removeRecovery(entry.sessionID, [entry.origin]); return undefined; } - if (outcome === "succeeded") { - await this.#removeRecovery(entry.sessionID); + if (session.outcome === "succeeded") { + await this.#removeRecovery(entry.sessionID, [entry.origin]); return undefined; } - if (outcome === undefined) { + if (session.outcome === undefined) { const firstCheck = this.#recoveryChecks.get(entry.sessionID) ?? this.#clock.now(); this.#recoveryChecks.set(entry.sessionID, firstCheck); if (this.#clock.now() - firstCheck < RECOVERY_SETTLE_CAP_MS) { return this.#clock.now() + RECOVERY_SETTLE_DELAY_MS; } - await this.#removeRecovery(entry.sessionID); + await this.#removeRecovery(entry.sessionID, [entry.origin]); this.#onError( new Error(`Offpeak recovery for ${entry.sessionID} skipped: the session did not settle.`), ); return undefined; } // Remove before sending so the continuation is delivered at most once. - await this.#removeRecovery(entry.sessionID); + await this.#removeRecovery(entry.sessionID, [entry.origin]); try { await this.#sessions.prompt(entry.sessionID, RECOVERY_CONTINUATION); } catch (error) { @@ -728,6 +843,55 @@ export class OffpeakEngine { return undefined; } + /** + * Delivers a pending-title recovery. This is a semantic watch, not a replay: + * + * - The session is re-checked for a still-missing automatic title. A title + * the session already has (generated by OpenCode or renamed by the user) + * clears the marker without sending anything, so a title is never + * overwritten and never regenerated twice. + * - A session that still needs a title is left to OpenCode's own generation: + * every path that loses a title hold (inactivity, supersede, shutdown, + * reload) also resumes the session — the plugin's same-session + * continuation or OpenCode's restart recovery — and that input promotion + * is OpenCode's native trigger for `SessionTitle.generate`. The marker + * waits for the title to appear and then clears. + * - OpenCode 2.0.27 exposes no plugin-facing regeneration call, and the + * plugin host's `session.update({ title: "" })` is not the HTTP API's + * regeneration either — it renames the session to an empty title — so the + * recovery deliberately mutates nothing. If no title appears within the + * settle cap, the marker is dropped with a diagnostic; a later input + * promotion still names the session normally. + * + * Returns a timestamp to re-check at while the title is still pending. + */ + async #deliverTitleRecovery(entry: RecoveryEntry): Promise { + let session: SessionSnapshot; + try { + session = await this.#sessions.get(entry.sessionID); + } catch (error) { + this.#onError(error); + await this.#removeRecovery(entry.sessionID, [TITLE_RECOVERY_ORIGIN]); + return undefined; + } + if (!needsAutoTitle(session)) { + await this.#removeRecovery(entry.sessionID, [TITLE_RECOVERY_ORIGIN]); + return undefined; + } + const firstCheck = this.#titleChecks.get(entry.sessionID) ?? this.#clock.now(); + this.#titleChecks.set(entry.sessionID, firstCheck); + if (this.#clock.now() - firstCheck < RECOVERY_SETTLE_CAP_MS) { + return this.#clock.now() + RECOVERY_SETTLE_DELAY_MS; + } + await this.#removeRecovery(entry.sessionID, [TITLE_RECOVERY_ORIGIN]); + this.#onError( + new Error( + `Offpeak pending title for ${entry.sessionID} was not generated after the allowed window; a later input promotion still names the session.`, + ), + ); + return undefined; + } + #scheduleRecovery(at?: number): void { if (this.#recoveryTimer) { this.#clock.clearTimeout(this.#recoveryTimer); diff --git a/src/index.ts b/src/index.ts index 52ba3cd..def45b0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,8 +13,9 @@ export type { GuardInput, RetryFailureInput, SessionDriver, + SessionSnapshot, } from "./engine.js"; -export { OffpeakEngine, PricingBlockedError } from "./engine.js"; +export { needsAutoTitle, OffpeakEngine, PricingBlockedError } from "./engine.js"; export type { PolicyDecision, PricingPolicy, @@ -33,6 +34,7 @@ export type { QueueDocument, RecoveryDocument, RecoveryEntry, + RecoveryOrigin, TaskModel, TaskState, } from "./store.js"; diff --git a/src/policy.ts b/src/policy.ts index 7b580aa..cdcd77a 100644 --- a/src/policy.ts +++ b/src/policy.ts @@ -12,9 +12,24 @@ export interface WeeklyWindow { export interface PricingPolicy { id: string; providers: string[]; + /** + * Patterns for models this policy prices with the weekly `blocked` windows: + * they are PEAK inside a window and OFF_PEAK outside it. + */ models: string[]; timeZone: string; blocked: WeeklyWindow[]; + /** + * Patterns for models of the same provider with no peak pricing at all: a + * single all-hours rate documented by the provider. They are OFF_PEAK at + * every hour and are allowed without waiting. A model that matches both + * `models` and `flatModels` is treated as peak-priced (`models` wins). + * + * These are vendor facts, not an assumption: they mirror the provider's own + * pricing page for the exact model IDs it lists at `verifiedAt`. A model + * matching neither list stays UNKNOWN and fails closed. + */ + flatModels?: string[]; /** * Full-day OFF_PEAK exception calendars, for example statutory public * holidays. A date listed in a calendar overrides every weekly PEAK window @@ -42,6 +57,15 @@ export interface PolicyDecision { // page documents the same windows for this provider // (https://opencode.ai/docs/go/). Holiday dates come from the State Council // calendar in src/holidays.ts. +// +// The Go pricing table lists Off-Peak/Peak tiers only for the DeepSeek models. +// Every other model in the Go catalog has one all-hours price per token, so +// `flatModels` mirrors that table exactly (model IDs from the documented Go +// endpoints list): GPT 6/5.6 Luna, Claude Haiku 5.5, Gemini 4 Argon +// Contributor, GLM, Kimi, LongCat, MiMo, MiniMax, Muse Spark, Qwen, Hy, +// Space Bunny, Step 5 Preview Free, Cat Jam Free, Grok. The layouts are +// deliberately explicit instead of a wildcard so that an unlisted model +// (including a future model with new peak pricing) still fails closed. export const DEEPSEEK_OPENCODE_GO: PricingPolicy = { id: "deepseek-opencode-go", providers: ["opencode-go"], @@ -51,6 +75,23 @@ export const DEEPSEEK_OPENCODE_GO: PricingPolicy = { "deepseek-v4-flash", "deepseek-v4-flash-vision-exp", ], + flatModels: [ + "grok-*", + "gpt-*", + "claude-*", + "gemini-*", + "glm-*", + "kimi-*", + "longcat-*", + "mimo-*", + "minimax-*", + "muse-*", + "qwen*", + "hy*", + "space-bunny", + "step-*", + "cat-jam-free", + ], timeZone: "UTC", blocked: [ { days: [1, 2, 3, 4, 5], start: "01:00", end: "04:00" }, @@ -59,7 +100,7 @@ export const DEEPSEEK_OPENCODE_GO: PricingPolicy = { holidays: CHINA_PUBLIC_HOLIDAYS, multiplier: 2, source: "https://opencode.ai/docs/go/", - verifiedAt: "2026-10-14", + verifiedAt: "2026-10-15", }; const dayNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; @@ -86,6 +127,11 @@ export function validatePolicy(policy: PricingPolicy): void { if (!policy.models.length || policy.models.some((item) => !item.trim())) { throw new Error(`Policy ${policy.id} must match at least one model.`); } + if (policy.flatModels !== undefined) { + if (!Array.isArray(policy.flatModels) || policy.flatModels.some((item) => !item.trim())) { + throw new Error(`Policy ${policy.id} flat model patterns must be non-empty strings.`); + } + } validateTimeZone(policy.timeZone); if (!policy.blocked.length) throw new Error(`Policy ${policy.id} must define blocked windows.`); for (const window of policy.blocked) { @@ -271,6 +317,15 @@ export function resolvePolicy( throw new Error(`More than one pricing policy matches ${providerID}/${modelID}.`); } if (!modelPolicies.length) { + // A flat-priced model has no peak window at all, so it is never blocked; + // `models` keeps precedence when a pattern matches both lists. + const flatPolicies = providerPolicies.filter((policy) => + (policy.flatModels ?? []).some((model) => matches(model, modelID)), + ); + if (flatPolicies.length > 1) { + throw new Error(`More than one flat pricing policy matches ${providerID}/${modelID}.`); + } + if (flatPolicies.length) return flatPolicies[0]; throw new Error( `Provider ${providerID} is configured, but model ${modelID} has no pricing policy.`, ); @@ -290,6 +345,14 @@ export function evaluatePricing( if (!policy) return { state: "UNTRACKED", allowed: true, reason: "No policy matches this provider." }; validatePolicy(policy); + if (!policy.models.some((model) => matches(model, modelID))) { + return { + state: "OFF_PEAK", + allowed: true, + policy, + reason: `${providerID}/${modelID} has flat all-hours pricing and no peak window.`, + }; + } const format = formatter(policy.timeZone); const peak = isBlockedAt(policy, nowMs, format); return { diff --git a/src/server.ts b/src/server.ts index a2e0b4e..aaff3f8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -32,8 +32,22 @@ export default Plugin.define({ wait: async (sessionID) => context.session.wait({ sessionID }), get: async (sessionID) => { const session = await context.session.get({ sessionID }); - return session.outcome ? { outcome: session.outcome } : {}; + // The wire format carries millis; the typed client exposes a DateTime. + const created = (session.time as { created?: unknown } | undefined)?.created; + const createdMs = typeof created === "number" ? created : Date.parse(String(created)); + return { + ...(session.outcome ? { outcome: session.outcome } : {}), + ...(session.title !== undefined ? { title: session.title } : {}), + ...(session.parentID ? { parentID: session.parentID } : {}), + ...(Number.isFinite(createdMs) ? { created: createdMs } : {}), + }; }, + // NOTE: OpenCode 2.0.27's plugin host does not expose a title generation + // call. `context.session.update({ title: "" })` is NOT the HTTP API's + // "regenerate" trigger: the plugin host renames the session to an empty + // title instead. The pending-title recovery therefore never mutates the + // session; it verifies and clears, and OpenCode's own input promotion + // generates the title (see #deliverTitleRecovery). interrupt: async (sessionID) => { await context.session.interrupt({ sessionID }); }, @@ -66,6 +80,7 @@ export default Plugin.define({ sessionID: request.sessionID, providerID: request.model.providerID, modelID: request.model.id, + kind: request.kind, transport: "http", }), ), @@ -74,6 +89,7 @@ export default Plugin.define({ sessionID: request.sessionID, providerID: request.model.providerID, modelID: request.model.id, + kind: request.kind, transport: "websocket", }), ), @@ -82,6 +98,7 @@ export default Plugin.define({ sessionID: request.sessionID, providerID: request.model.providerID, modelID: request.model.id, + kind: request.kind, transport: "websocket", }), ), diff --git a/src/store.ts b/src/store.ts index af116de..fc4ee6c 100644 --- a/src/store.ts +++ b/src/store.ts @@ -41,10 +41,15 @@ export interface QueueDocument { * at the next allowed window. The session itself is untouched; only the * identity needed to continue it is kept. * - * `origin` distinguishes a request that failed retryably during a peak crossing - * ("retry-failure") from a primary interactive request that was deferred before - * its provider attempt ("deferred"). Both continue automatically; a user - * interrupt clears a deferral but not a retry-failure. + * `origin` distinguishes: + * - "retry-failure": a request that failed retryably during a peak crossing; + * - "deferred": a primary interactive request deferred before its provider + * attempt. Both continue the same session automatically; a user interrupt + * clears a deferral but not a retry-failure. + * - "pending-title": the session still needs its automatic OpenCode-generated + * title. Recovery is semantic (OpenCode's own title generation is invoked + * again; no provider request is replayed) and never overwrites a title the + * session already has. */ export interface RecoveryEntry { sessionID: string; @@ -52,9 +57,11 @@ export interface RecoveryEntry { modelID: string; directory: string; heldAt: string; - origin?: "retry-failure" | "deferred"; + origin?: RecoveryOrigin; } +export type RecoveryOrigin = "retry-failure" | "deferred" | "pending-title"; + export interface RecoveryDocument { schemaVersion: 1; entries: RecoveryEntry[]; @@ -228,7 +235,8 @@ function validateRecoveryEntry(value: unknown, index: number): RecoveryEntry { if ( value.origin !== undefined && value.origin !== "retry-failure" && - value.origin !== "deferred" + value.origin !== "deferred" && + value.origin !== "pending-title" ) { throw new Error(`Stored recovery entry ${index} has an invalid origin.`); } diff --git a/test/engine.test.mjs b/test/engine.test.mjs index 406cd9b..2d84f0e 100644 --- a/test/engine.test.mjs +++ b/test/engine.test.mjs @@ -81,6 +81,10 @@ class FakeSessions { outcomes = new Map(); behavior = async () => undefined; createdTasks = []; + snapshots = new Map(); + missing = new Set(); + titles = []; + titleBehavior = async () => undefined; async create(input) { const id = `session-${++this.next}`; this.createdTasks.push({ id, taskID: input.taskID, cwd: input.cwd, model: input.model }); @@ -102,8 +106,14 @@ class FakeSessions { await this.workers.get(sessionID)?.catch(() => undefined); } async get(sessionID) { + if (this.missing.has(sessionID)) throw new Error(`Session not found: ${sessionID}`); const outcome = this.outcomes.get(sessionID); - return outcome ? { outcome } : {}; + const extra = this.snapshots.get(sessionID) ?? {}; + return { ...(outcome ? { outcome } : {}), ...extra }; + } + async generateTitle(sessionID) { + this.titles.push(sessionID); + await this.titleBehavior(sessionID); } async interrupt(sessionID) { this.outcomes.set(sessionID, "interrupted"); @@ -1388,7 +1398,7 @@ test("a primary model.request guard defers the interactive request before transp } }); -test("an auxiliary model.request hold waits without registering a recovery deferral", async () => { +test("an auxiliary model.request hold waits without a primary deferral; a title hold persists a pending-title marker", async () => { const clock = new ManualClock("2026-10-12T01:30:00Z"); const h = await harness("2026-10-12T01:30:00Z", { clock }); let releases = 0; @@ -1404,16 +1414,52 @@ test("an auxiliary model.request hold waits without registering a recovery defer }); try { await until(async () => (await h.engine.status(selectedModel)).interactiveWaiting === 1); - assert.equal((await h.engine.status(selectedModel)).recoveryWaiting, 0); + // A held title request persists a semantic pending-title marker; it is not + // a primary continuation deferral. + assert.equal((await h.engine.status(selectedModel)).recoveryWaiting, 1); + assert.equal((await h.engine.status(selectedModel)).titleRecoveryWaiting, 1); await clock.advanceTo("2026-10-12T04:00:00Z"); await until(async () => releases === 1); assert.equal((await h.engine.status(selectedModel)).recoveryWaiting, 0); + assert.equal((await h.engine.status(selectedModel)).titleRecoveryWaiting, 0); await pending; } finally { await h.close(); } }); +test("compaction and generate model.request holds register no recovery of any kind", async () => { + const clock = new ManualClock("2026-10-12T01:30:00Z"); + const h = await harness("2026-10-12T01:30:00Z", { clock }); + const holds = [ + h.engine + .guardModelRequest({ + sessionID: "compaction-session", + ...selectedModel, + kind: "compaction", + transport: "model", + }) + .then(() => "released"), + h.engine + .guardModelRequest({ + sessionID: "generate-session", + ...selectedModel, + kind: "generate", + transport: "model", + }) + .then(() => "released"), + ]; + try { + await until(async () => (await h.engine.status(selectedModel)).interactiveWaiting === 2); + assert.equal((await h.engine.status(selectedModel)).recoveryWaiting, 0); + await clock.advanceTo("2026-10-12T04:00:00Z"); + assert.deepEqual(await Promise.all(holds), ["released", "released"]); + assert.equal((await h.engine.status(selectedModel)).recoveryWaiting, 0); + } finally { + await h.close(); + } +}); + test("a deferred primary request survives a plugin reload and continues the same session once at the boundary", async () => { const clock = new ManualClock("2026-10-12T01:30:00Z"); const h = await harness("2026-10-12T01:30:00Z", { clock }); @@ -1613,19 +1659,330 @@ test("a shutdown interrupt leaves the deferral in place for restart recovery", a } }); -test("a stored copy of the built-in policy adopts the built-in holiday calendar", async () => { +test("a pending-title marker survives a plugin reload and clears once OpenCode names the session", async () => { + const clock = new ManualClock("2026-10-12T01:30:00Z"); + const h = await harness("2026-10-12T01:30:00Z", { clock }); + const sessionID = "pending-title-reload"; + const pending = h.engine + .guardModelRequest({ sessionID, ...selectedModel, kind: "title", transport: "model" }) + .catch(() => "blocked"); + try { + await until(async () => (await h.engine.status(selectedModel)).titleRecoveryWaiting === 1); + // The plugin reloads mid-hold: the held request fails closed, the marker stays. + await h.engine.dispose(); + assert.equal(await pending, "blocked"); + const restarted = new OffpeakEngine({ + repository: new QueueRepository(h.storage), + recoveries: new RecoveryRepository(h.storage), + location: "test-location", + config: h.config, + sessions: h.sessions, + clock, + }); + await restarted.start(); + assert.equal((await restarted.status(selectedModel)).titleRecoveryWaiting, 1); + // OpenCode's own promotion names the session once the turn continues. + h.sessions.snapshots.set(sessionID, { + title: "Recovered title", + created: Date.parse("2026-10-12T01:30:00Z"), + }); + await clock.advanceTo("2026-10-12T04:00:00Z"); + await until(async () => (await restarted.status(selectedModel)).titleRecoveryWaiting === 0); + // The recovery itself never mutates the session or sends a title request. + assert.equal(h.sessions.titles.length, 0); + assert.equal(h.sessions.prompts.length, 0); + assert.deepEqual(await restarted.list(), []); + await clock.advanceTo("2026-10-13T00:00:00Z"); + await new Promise((resolve) => setTimeout(resolve, 25)); + assert.equal(h.sessions.titles.length, 0); + assert.equal(h.sessions.snapshots.get(sessionID).title, "Recovered title"); + await restarted.dispose(); + } finally { + await h.close(); + } +}); + +test("a pending-title recovery never overwrites a manual rename and clears its marker", async () => { + const clock = new ManualClock("2026-10-12T01:30:00Z"); + const h = await harness("2026-10-12T01:30:00Z", { clock }); + const sessionID = "pending-title-renamed"; + const pending = h.engine + .guardModelRequest({ sessionID, ...selectedModel, kind: "title", transport: "model" }) + .catch(() => "blocked"); + try { + await until(async () => (await h.engine.status(selectedModel)).titleRecoveryWaiting === 1); + h.sessions.snapshots.set(sessionID, { + title: "My manually chosen title", + created: Date.parse("2026-10-12T01:30:00Z"), + }); + await clock.advanceTo("2026-10-12T04:00:00Z"); + await until(async () => (await h.engine.status(selectedModel)).titleRecoveryWaiting === 0); + assert.equal(h.sessions.titles.length, 0); + assert.equal(h.sessions.snapshots.get(sessionID).title, "My manually chosen title"); + } finally { + await h.close(); + await pending; + } +}); + +test("a pending-title recovery clears without a request when the title already exists", async () => { + const clock = new ManualClock("2026-10-12T01:30:00Z"); + const h = await harness("2026-10-12T01:30:00Z", { clock }); + const sessionID = "pending-title-completed"; + const pending = h.engine + .guardModelRequest({ sessionID, ...selectedModel, kind: "title", transport: "model" }) + .catch(() => "blocked"); + try { + await until(async () => (await h.engine.status(selectedModel)).titleRecoveryWaiting === 1); + h.sessions.snapshots.set(sessionID, { + title: "Generated by another process", + created: Date.parse("2026-10-12T01:30:00Z"), + }); + await clock.advanceTo("2026-10-12T04:00:00Z"); + await until(async () => (await h.engine.status(selectedModel)).titleRecoveryWaiting === 0); + assert.equal(h.sessions.titles.length, 0); + } finally { + await h.close(); + await pending; + } +}); + +test("a pending title waits for OpenCode's promotion and never mutates the session itself", async () => { + const clock = new ManualClock("2026-10-12T01:30:00Z"); + const h = await harness("2026-10-12T01:30:00Z", { clock }); + const sessionID = "pending-title-fallback"; + const created = Date.parse("2026-10-12T01:30:00Z"); + const pending = h.engine + .guardModelRequest({ sessionID, ...selectedModel, kind: "title", transport: "model" }) + .catch(() => "blocked"); + try { + await until(async () => (await h.engine.status(selectedModel)).titleRecoveryWaiting === 1); + // The exact creation-time fallback still means the session needs a title. + h.sessions.snapshots.set(sessionID, { + title: `New session - ${new Date(created).toISOString()}`, + created, + }); + // An interruption (not a normal release) leaves the marker behind. + await h.engine.noteExecutionInterrupted({ sessionID, reason: "inactivity" }); + assert.equal(await pending, "blocked"); + await clock.advanceTo("2026-10-12T04:00:00Z"); + // The marker keeps watching (bounded by the settle cap) instead of + // renaming anything itself. + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.equal((await h.engine.status(selectedModel)).titleRecoveryWaiting, 1); + assert.equal(h.sessions.titles.length, 0); + // OpenCode's promotion names it; the marker then clears on the next check. + h.sessions.snapshots.set(sessionID, { title: "Promoted title", created }); + await clock.advanceTo("2026-10-12T04:00:04Z"); + await until(async () => (await h.engine.status(selectedModel)).titleRecoveryWaiting === 0); + assert.equal(h.sessions.titles.length, 0); + assert.equal(h.sessions.snapshots.get(sessionID).title, "Promoted title"); + } finally { + await h.close(); + await pending; + } +}); + +test("a pending-title recovery is skipped for child sessions and unknown sessions", async () => { + const clock = new ManualClock("2026-10-12T01:30:00Z"); + const h = await harness("2026-10-12T01:30:00Z", { clock }); + const child = "pending-title-child"; + const missing = "pending-title-missing"; + const holds = [ + h.engine + .guardModelRequest({ sessionID: child, ...selectedModel, kind: "title", transport: "model" }) + .catch(() => "blocked"), + h.engine + .guardModelRequest({ + sessionID: missing, + ...selectedModel, + kind: "title", + transport: "model", + }) + .catch(() => "blocked"), + ]; + try { + await until(async () => (await h.engine.status(selectedModel)).titleRecoveryWaiting === 2); + // A reload persists both markers; the child is a subagent session, the other + // session is gone entirely. + h.sessions.snapshots.set(child, { parentID: "parent-session" }); + h.sessions.missing.add(missing); + await h.engine.dispose(); + assert.deepEqual(await Promise.all(holds), ["blocked", "blocked"]); + const restarted = new OffpeakEngine({ + repository: new QueueRepository(h.storage), + recoveries: new RecoveryRepository(h.storage), + location: "test-location", + config: h.config, + sessions: h.sessions, + clock, + }); + await restarted.start(); + assert.equal((await restarted.status(selectedModel)).titleRecoveryWaiting, 2); + await clock.advanceTo("2026-10-12T04:00:00Z"); + await until(async () => (await restarted.status(selectedModel)).titleRecoveryWaiting === 0); + assert.equal(h.sessions.titles.length, 0); + await restarted.dispose(); + } finally { + await h.close(); + } +}); + +test("a user cancel clears a pending-title marker; an inactivity interrupt keeps it", async () => { + const clock = new ManualClock("2026-10-12T01:30:00Z"); + const h = await harness("2026-10-12T01:30:00Z", { clock }); + const cancelled = "pending-title-cancelled"; + const interrupted = "pending-title-interrupted"; + const holds = [ + h.engine + .guardModelRequest({ + sessionID: cancelled, + ...selectedModel, + kind: "title", + transport: "model", + }) + .then( + () => "resolved", + () => "blocked", + ), + h.engine + .guardModelRequest({ + sessionID: interrupted, + ...selectedModel, + kind: "title", + transport: "model", + }) + .then( + () => "resolved", + () => "blocked", + ), + ]; + try { + await until(async () => (await h.engine.status(selectedModel)).titleRecoveryWaiting === 2); + await h.engine.noteExecutionInterrupted({ sessionID: cancelled, reason: "user" }); + await h.engine.noteExecutionInterrupted({ sessionID: interrupted, reason: "inactivity" }); + assert.deepEqual(await Promise.all(holds), ["blocked", "blocked"]); + // The cancelled session keeps no marker; the inactivity-interrupted one does. + assert.equal((await h.engine.status(selectedModel)).titleRecoveryWaiting, 1); + // OpenCode's continuation promotion names the session after the boundary. + h.sessions.snapshots.set(interrupted, { + title: "After inactivity", + created: Date.parse("2026-10-12T01:30:00Z"), + }); + await clock.advanceTo("2026-10-12T04:00:00Z"); + await until(async () => (await h.engine.status(selectedModel)).titleRecoveryWaiting === 0); + assert.equal(h.sessions.titles.length, 0); + assert.equal(h.sessions.snapshots.get(interrupted).title, "After inactivity"); + } finally { + await h.close(); + } +}); + +test("a delivered primary continuation lets OpenCode name the session; the title marker only verifies", async () => { + const clock = new ManualClock("2026-10-12T01:30:00Z"); + const h = await harness("2026-10-12T01:30:00Z", { clock }); + const sessionID = "pending-title-with-continuation"; + const created = Date.parse("2026-10-12T01:30:00Z"); + // Both requests are held: the title marker and the primary deferral coexist. + const holds = [ + h.engine + .guardModelRequest({ sessionID, ...selectedModel, kind: "title", transport: "model" }) + .catch(() => "blocked"), + h.engine + .guardModelRequest({ sessionID, ...selectedModel, kind: "primary", transport: "model" }) + .catch(() => "blocked"), + ]; + try { + await until(async () => (await h.engine.status(selectedModel)).recoveryWaiting === 2); + assert.equal((await h.engine.status(selectedModel)).titleRecoveryWaiting, 1); + // OpenCode's own promotion of the continuation names the session. + h.sessions.outcomes.set(sessionID, "interrupted"); + h.sessions.workers.set(sessionID, Promise.resolve()); + h.sessions.behavior = async ({ sessionID: id }) => { + h.sessions.snapshots.set(id, { title: "Continuation title", created }); + return { outcome: "succeeded" }; + }; + await clock.advanceTo("2026-10-12T04:00:00Z"); + await until(async () => h.sessions.prompts.length === 1); + await until(async () => (await h.engine.status(selectedModel)).recoveryWaiting === 0); + // The continuation carried the title; the marker never generated one itself. + assert.equal(h.sessions.titles.length, 0); + assert.equal(h.sessions.snapshots.get(sessionID).title, "Continuation title"); + await Promise.all(holds); + } finally { + await h.close(); + } +}); + +test("multiple reloads keep one pending-title marker until OpenCode names the session", async () => { + const clock = new ManualClock("2026-10-12T01:30:00Z"); + const h = await harness("2026-10-12T01:30:00Z", { clock }); + const sessionID = "pending-title-multi-reload"; + const hold = h.engine + .guardModelRequest({ sessionID, ...selectedModel, kind: "title", transport: "model" }) + .catch(() => "blocked"); + try { + await until(async () => (await h.engine.status(selectedModel)).titleRecoveryWaiting === 1); + await h.engine.dispose(); + await hold; + let engine = h.engine; + for (let round = 0; round < 3; round += 1) { + engine = new OffpeakEngine({ + repository: new QueueRepository(h.storage), + recoveries: new RecoveryRepository(h.storage), + location: "test-location", + config: h.config, + sessions: h.sessions, + clock, + }); + await engine.start(); + assert.equal((await engine.status(selectedModel)).titleRecoveryWaiting, 1); + await engine.dispose(); + } + h.sessions.snapshots.set(sessionID, { + title: "Native name", + created: Date.parse("2026-10-12T01:30:00Z"), + }); + const final = new OffpeakEngine({ + repository: new QueueRepository(h.storage), + recoveries: new RecoveryRepository(h.storage), + location: "test-location", + config: h.config, + sessions: h.sessions, + clock, + }); + await final.start(); + await clock.advanceTo("2026-10-12T04:00:00Z"); + await until(async () => (await final.status(selectedModel)).titleRecoveryWaiting === 0); + await clock.advanceTo("2026-10-13T00:00:00Z"); + await new Promise((resolve) => setTimeout(resolve, 25)); + assert.equal(h.sessions.titles.length, 0); + assert.equal((await final.status(selectedModel)).titleRecoveryWaiting, 0); + await final.dispose(); + } finally { + await h.close(); + } +}); + +test("a stored copy of the built-in policy adopts the built-in holiday calendar and flat model list", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "opencode-offpeak-policy-adopt-")); const configPath = path.join(root, "offpeak.json"); const builtin = defaultConfig().policies[0]; const legacy = structuredClone(builtin); delete legacy.holidays; + delete legacy.flatModels; legacy.verifiedAt = "2026-10-09"; const customized = { ...structuredClone(legacy), id: "custom-window", blocked: [{ days: [1], start: "22:00", end: "23:00" }], }; - const explicit = { ...structuredClone(legacy), id: "explicit-holidays", holidays: [] }; + const explicit = { + ...structuredClone(legacy), + id: "explicit-lists", + holidays: [], + flatModels: ["only-this-model"], + }; try { await writeFile( configPath, @@ -1643,11 +2000,42 @@ test("a stored copy of the built-in policy adopts the built-in holiday calendar" const [adopted, kept, keptExplicit] = read.config.policies; assert.equal(adopted.id, builtin.id); assert.deepEqual(adopted.holidays, builtin.holidays); - // A customized window is preserved exactly and gains no holiday data. + // The live v0.1.4 copy pinned before the flat model list existed adopts it + // at read time, which is what restores automatic titles on the live install. + assert.deepEqual(adopted.flatModels, builtin.flatModels); + // A customized window is preserved exactly and gains no built-in data. assert.deepEqual(kept.blocked, customized.blocked); assert.equal(kept.holidays, undefined); - // An explicit holiday list (even empty) is never overwritten. + assert.equal(kept.flatModels, undefined); + // Explicit lists are never replaced by the built-in ones. assert.deepEqual(keptExplicit.holidays, []); + assert.deepEqual(keptExplicit.flatModels, ["only-this-model"]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("explicit holiday and flat lists on the built-in policy id are preserved on read", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "opencode-offpeak-policy-explicit-")); + const configPath = path.join(root, "offpeak.json"); + const builtin = defaultConfig().policies[0]; + const explicit = { ...structuredClone(builtin), holidays: [], flatModels: ["only-this-model"] }; + try { + await writeFile( + configPath, + JSON.stringify({ + schemaVersion: 1, + enabled: true, + mode: "auto", + displayTimeZone: "UTC", + policies: [explicit], + }), + ); + const config = new ConfigRepository({ configPath }); + const read = await config.read(); + assert.equal(read.error, undefined); + assert.deepEqual(read.config.policies[0].holidays, []); + assert.deepEqual(read.config.policies[0].flatModels, ["only-this-model"]); } finally { await rm(root, { recursive: true, force: true }); } diff --git a/test/policy.test.mjs b/test/policy.test.mjs index 3b35c80..39f4b35 100644 --- a/test/policy.test.mjs +++ b/test/policy.test.mjs @@ -89,6 +89,112 @@ test("unknown models for a configured provider fail closed; unrelated providers assert.equal(unrelated.allowed, true); }); +test("documented flat models have no peak window and stay allowed during DeepSeek peak hours", () => { + // The Go pricing table lists peak/off-peak tiers only for the DeepSeek models. + for (const modelID of [ + "gpt-6-luna", + "gpt-5.6-luna", + "claude-haiku-5-5", + "gemini-4-argon-contributor", + "glm-5.3-flash", + "glm-5.3", + "glm-5.2", + "kimi-k3", + "kimi-k2.7-code", + "longcat-2.0", + "mimo-v2.6-flash", + "minimax-m3", + "muse-spark-1.3-contributor", + "qwen3.8-flash", + "hy3", + "space-bunny", + "step-5-preview-free", + "cat-jam-free", + "grok-4.7", + "longcat-2.5-preview-free", + ]) { + const decision = evaluatePricing( + [DEEPSEEK_OPENCODE_GO], + provider, + modelID, + at("2026-10-12T02:00:00Z"), + ); + assert.equal(decision.state, "OFF_PEAK", modelID); + assert.equal(decision.allowed, true, modelID); + assert.equal(decision.policy?.id, "deepseek-opencode-go", modelID); + assert.equal(decision.nextTransition, undefined, modelID); + // The same flat model is never blocked at any hour, including peak edges. + assert.equal( + evaluatePricing([DEEPSEEK_OPENCODE_GO], provider, modelID, at("2026-10-12T06:00:00Z")).state, + "OFF_PEAK", + modelID, + ); + } +}); + +test("the peak-priced DeepSeek models keep their windows next to the flat model list", () => { + for (const modelID of ["deepseek-v4.1-flash", "deepseek-v4-pro"]) { + assert.equal( + evaluatePricing([DEEPSEEK_OPENCODE_GO], provider, modelID, at("2026-10-12T02:00:00Z")).state, + "PEAK", + modelID, + ); + assert.equal( + evaluatePricing([DEEPSEEK_OPENCODE_GO], provider, modelID, at("2026-10-12T05:00:00Z")).state, + "OFF_PEAK", + modelID, + ); + } +}); + +test("a model matching both the peak and flat patterns stays peak-priced", () => { + const policy = { + ...DEEPSEEK_OPENCODE_GO, + id: "overlap-test", + models: ["deepseek-v4.1-flash"], + flatModels: ["deepseek-*"], + }; + assert.equal( + evaluatePricing([policy], provider, "deepseek-v4.1-flash", at("2026-10-12T02:00:00Z")).state, + "PEAK", + ); + assert.equal( + evaluatePricing([policy], provider, "deepseek-other", at("2026-10-12T02:00:00Z")).state, + "OFF_PEAK", + ); +}); + +test("a model matching neither list on a configured provider still fails closed", () => { + const decision = evaluatePricing( + [DEEPSEEK_OPENCODE_GO], + provider, + "future-model-x", + at("2026-10-12T05:00:00Z"), + ); + assert.equal(decision.state, "UNKNOWN"); + assert.equal(decision.allowed, false); +}); + +test("ambiguous flat policies and invalid flat patterns fail closed", () => { + const ambiguous = [ + { ...DEEPSEEK_OPENCODE_GO, id: "flat-one", flatModels: ["shared-*"] }, + { ...DEEPSEEK_OPENCODE_GO, id: "flat-two", flatModels: ["shared-*"] }, + ]; + const decision = evaluatePricing( + [...ambiguous], + provider, + "shared-model", + at("2026-10-12T05:00:00Z"), + ); + assert.equal(decision.state, "UNKNOWN"); + assert.equal(decision.allowed, false); + const invalid = { ...DEEPSEEK_OPENCODE_GO, id: "bad-flat", flatModels: [" "] }; + assert.equal( + evaluatePricing([invalid], provider, "gpt-6-luna", at("2026-10-12T05:00:00Z")).state, + "UNKNOWN", + ); +}); + test("invalid timezone and schedule data fail closed", () => { const invalid = { ...DEEPSEEK_OPENCODE_GO,