diff --git a/.grace/changes/archive/C-SESSION-OWNED-SETTINGS/plan.xml b/.grace/changes/archive/C-SESSION-OWNED-SETTINGS/plan.xml new file mode 100644 index 00000000..5a6d1465 --- /dev/null +++ b/.grace/changes/archive/C-SESSION-OWNED-SETTINGS/plan.xml @@ -0,0 +1,250 @@ + + + Replace the family snapshot subsystem with session-owned settings in native session metadata. One new resolver module owns the narrow settings record (decode, birth from the effective config, lineage resolution, read-modify-write projection, migration); the shared native runtime drops the bind/guard/variant machinery and exposes the settings service; every plugin consumer switches from snapshots.configFor/accept to settings.configFor; explicit change arrives through /vv-settings in the TUI and `vvoc session set` in the CLI; the snapshot store, snapshot service, rebind markers, force flags, and /vv-rebind are deleted with their tests; README and the GRACE graph/verification projections are synchronized. Approved: the approved factory task is recorded as the owner authorization for both spec and plan. + + + M-NATIVE-RUNTIMEV-M-NATIVE-RUNTIME + src/runtime/context.ts + src/runtime/snapshots.ts + src/runtime/snapshot-store.ts + src/runtime/snapshot-config.ts + src/lib/rebind-request.ts + src/tui/rebind/plugin.ts + src/plugins/workflow.integration.test.ts + README.md + + src/runtime/types.tsSnapshotAdmissionError + src/runtime/context.tsguardResolvedModel + src/plugins/workflow.integration.test.tsa bound family keeps its captured profile while new work sees the changed policy + + bun test src/runtime/model-registry.test.ts + bun run typecheck + + + + M-NATIVE-RUNTIME + src/runtime/session-settings.tsvvoc:settings + src/runtime/session-settings.tsexport interface FamilySettings + src/runtime/context.tssessionSettings + src/commands/session.tspull-defaults + src/tui/settings/plugin.tsvv-settings + README.md/vv-settings + src/runtime/context.tsguardResolvedModel + src/runtime/types.tsSnapshotAdmissionError + + src/runtime/snapshots.ts + src/runtime/snapshot-store.ts + src/lib/rebind-request.ts + src/tui/rebind/plugin.ts + + + bash -c "grep -rE 'SnapshotAdmissionError|SnapshotUnboundError|SnapshotStoreError' src/ >/dev/null 2>&1 && exit 1 || exit 0" + bun test src/runtime/session-settings.test.ts + bun test src/plugins/workflow.integration.test.ts + bun run check + bun run build + grace lint --path . + + + + Approval provenance: the approved factory task authorizes translating this bundle to approved and executing it; recorded per the GRACE approval rule. No separate user approval step remains. + Metadata semantics: session.update REPLACES the whole metadata map, so the only write path is read-modify-write of the `vvoc:settings` key; auxiliary imports create new sessions and never count as metadata writes. + Settings record format is fixed: {version:1, preset?, roles, profile, plugins} with resolved values; plugin toggles keep resolved booleans only, so object-valued plugin entries (schedules, modes) always resolve from the live configuration. + Projection semantics: the consumer-facing projected config deep-clones the live effective config and overrides roles, orchestration.profile, and the plugin toggle booleans governed by the family record; credentials never come from metadata. + Role application after the change: the family root model is never switched or validated; children apply roles at creation (agent editor transforms stay creation-level; host-created children get a one-shot creation reconciliation switch from family settings; auxiliary children carry the model in the import payload as today). The model-roles toggle gates all role application. + Race discipline: inside the runtime, per-family RMW serializes through the existing coordination family lock; the birth path caches the born fact per session id in-process; CLI/TUI perform a single RMW and the window is documented. + Migration: runs at first runtime acquisition per project scope; converts each stored binding into the root's metadata for live roots, skips dead roots, deletes the scope's bindings/snapshots/rebind directories only after a fully successful pass, and keeps them for retry on any failure. + Host pin: the e2e harness pin follows docs/host-updates.md; if the 2.2.6 binary cannot be verified live in this environment the change records an explicit residual note instead of a fabricated hash, and the harness keeps the newest verified pin. + Known-flakes item 3: bun run check on an unpatched checkout failed at the grace markup gate. All markup edits are in scope here, so the gate must pass on the finished tree; judged only after the gate finishes. + No final-review obligation is declared by the source; the factory verify stage reviews the result. GRACE lifecycle commands stay external and never nest inside plan commands. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + src/runtime/session-settings.ts + src/runtime/session-settings.test.ts + src/runtime/context.ts + src/runtime/context.test.ts + src/runtime/types.ts + src/runtime/snapshot-config.ts + src/runtime/snapshot-config.test.ts + src/runtime/auxiliary.ts + src/runtime/auxiliary.test.ts + src/runtime/model-registry.ts + src/runtime/model-registry.test.ts + src/runtime/context-inspection.ts + src/runtime/context-inspection.test.ts + src/runtime/snapshots.ts + src/runtime/snapshots.test.ts + src/runtime/snapshot-store.ts + src/runtime/snapshot-store.test.ts + src/lib/rebind-request.ts + src/lib/rebind-request.test.ts + src/tui.tsx + src/tui/settings/plugin.ts + src/tui/settings/plugin.test.ts + src/tui/rebind/plugin.ts + src/tui/rebind/plugin.test.ts + src/cli.ts + src/commands/session.ts + src/commands/session.test.ts + src/commands/preset.ts + src/commands/preset.test.ts + src/commands/role.ts + src/commands/role.test.ts + src/commands/plugin-toggle.ts + src/commands/plugin-toggle.test.ts + src/plugins/analytics/index.ts + src/plugins/guardian/index.ts + src/plugins/hashline-edit/index.ts + src/plugins/model-roles/index.ts + src/plugins/peak-hours/index.ts + src/plugins/secrets-redaction/index.ts + src/plugins/spec-guard/index.ts + src/plugins/system-context-injection/index.ts + src/plugins/tool-history-compaction/index.ts + src/plugins/web-tools/index.ts + src/plugins/workflow/index.ts + src/plugins/workflow.integration.test.ts + src/plugins/model-roles.integration.test.ts + src/plugins/guardian.integration.test.ts + src/plugins/system-context-injection.integration.test.ts + src/plugins/tool-history-compaction.integration.test.ts + src/plugins/web-tools.integration.test.ts + src/plugins/hashline-edit.integration.test.ts + src/plugins/telegram.integration.test.ts + scripts/e2e-v2/cases.ts + scripts/e2e-v2/core.ts + scripts/e2e-v2/host.ts + scripts/e2e-v2/full-cases.ts + src/hosts.json + README.md + .grace/graph/main.xml + .grace/verification/main.xml + .grace/changes/active/C-SESSION-OWNED-SETTINGS/spec.xml + .grace/changes/active/C-SESSION-OWNED-SETTINGS/plan.xml + + + + Build the session-settings module: record format, decode, birth, lineage resolution, RMW projection, migration. + + + src/runtime/session-settings.ts defines the versioned FamilySettings record, strict decode (invalid input yields undefined plus a bounded reason), settingsFromConfig from the effective runtime config, the projected consumer view (live config with family-governed fields overridden, credentials only from live), lineage-to-root resolution ported from resolveSessionFamily with cycle/depth guards, and the RMW helper that merges only the `vvoc:settings` key into the full metadata map. + The migration converter reads a bindings scope directory, projects each stored snapshot into the narrow record, RMW-writes it into live root sessions, skips dead roots, and removes the bindings/snapshots/rebind directories only after full success. + Colocated tests cover: decode acceptance/rejection, from-config values, projection merge semantics (foreign live fields survive, toggles override), RMW preserving a foreign metadata key, lineage cycle/depth refusal, and migration one-shot with second-run no-op plus dead-root skip and failure retention. + Module contract, map, and change summary anchors are accurate. + + + bun test src/runtime/session-settings.test.ts + bun run typecheck + + + + Rewire the shared native runtime onto the settings service and delete the guard machinery. + T-001 + + The native snapshot context seam widens session.update to accept metadata and keeps the compile-time Plugin.Context proof. + The runtime exposes a settings service (familyOf, configFor with lazy birth for roots, resolve, explicit update, auxiliary) and drops: bindFor, guardResolvedModel, prompt/generate/model.request guard hooks, explicitSelections/pluginSwitches/firstWorkPending/creationModels/creationDefaults/suppressedTitles state, variant materialization and pending registrations, admitOwned, rebind, removeFamily wiring, and the snapshot-service registry. + A one-shot creation reconciliation switches host-created children to the family's role model when role overriding is enabled, once per child, without any later validation; the title auxiliary path resolves roles from family settings; the config watcher keeps refreshing the live config; session deletion keeps only in-process cache hygiene. + Migration runs once at first runtime acquisition per project scope, fail-soft. + src/runtime/types.ts drops the snapshot vocabulary that loses all consumers (SnapshotService, SnapshotStore, StagedCandidate, AdmissionOutcome, accepted-input helpers, guard error classes) and adds the settings types; snapshot-config.ts keeps effectiveRuntimeConfig/parseRoleSelections/agent bindings helpers and drops the behaviour projection/hash/provenance/watch code; model-registry.ts drops variant registration and qualification helpers that lose consumers. + Deleted files: src/runtime/snapshots.ts{,.test.ts}, src/runtime/snapshot-store.ts{,.test.ts}, src/lib/rebind-request.ts{,.test.ts}; grep for SnapshotAdmissionError|SnapshotUnboundError|SnapshotStoreError|firstWorkPending in src/ is empty. + context.test.ts and auxiliary.test.ts reflect the new surface; module anchors stay accurate. + + + bun test src/runtime + bash -c "grep -rE 'SnapshotAdmissionError|SnapshotUnboundError|SnapshotStoreError|firstWorkPending' src/ >/dev/null 2>&1 && exit 1 || exit 0" + bun run typecheck + + + + Move all plugin consumers onto the settings resolver. + T-002 + + workflow, system-context-injection, analytics, guardian, hashline-edit, peak-hours, secrets-redaction, spec-guard, tool-history-compaction, web-tools, model-roles resolve family policy through runtime.settings.configFor (one call, no accept retry); the workflow WorkflowRuntime surface narrows to the settings service. + The rewritten workflow integration test proves AC-01: a session whose family record carries profile X keeps resolving X after the underlying config flips to Y, and a fresh session resolves Y. + context-inspection projects the settings view through the same allowlisted policy DTO with value-free errors; its tests cover the new source. + model-roles keeps setRoleOverride as the role-application toggle; the model-roles integration suite is rewritten for creation-level role application (editor transforms plus one-shot child reconciliation) with the guard/bind scenarios removed or converted to birth/resolve coverage. + Other plugin integration suites that inject fake snapshot runtimes are updated to the settings surface without behavior regression. + + + bun test src/plugins/workflow.integration.test.ts src/plugins/model-roles.integration.test.ts + bun test src/plugins/guardian.integration.test.ts src/plugins/system-context-injection.integration.test.ts src/plugins/tool-history-compaction.integration.test.ts src/plugins/web-tools.integration.test.ts src/plugins/hashline-edit.integration.test.ts + bun run typecheck + + + + Explicit change commands: /vv-settings TUI command and vvoc session set CLI; remove --force and rebind plumbing. + T-002 + + src/tui/settings/plugin.ts registers /vv-settings beside /context using the existing slot/keymap pattern: preset | profile | role | plugin toggle actions plus a pull-current-defaults action, writing through the running runtime's settings update for the active session's family root, one bounded toast per outcome, disabled without an open session. + src/commands/session.ts implements `vvoc session set --session <id>` with --preset | --profile | --role id=model | --role unset:id | --plugin name=on|off | --pull-defaults; it discovers the running host through Service.discover/Service.headers, resolves lineage to the root, and performs one RMW; no live host is a clear error. Registered in src/cli.ts. + preset.ts, role.ts, plugin-toggle.ts lose --force/--session flags and all rebind-marker calls; preset/role tests and plugin-toggle tests are updated. + src/tui.tsx drops the rebind facade and registers the settings command; tui/rebind/* is deleted with its test; new command tests cover the isolation property (only the named family changes) and the RMW foreign-key property. + + + bun test src/commands/session.test.ts src/commands/preset.test.ts src/commands/role.test.ts src/commands/plugin-toggle.test.ts + bun test src/tui/settings/plugin.test.ts + bun run typecheck + + + + Rewrite the e2e-v2 harness binding surface for session settings and settle the host pin. + T-002 + + cases.ts binding cases and core.ts REPLAY_RESTART assert the settings/policy projection instead of capture.snapshotId and family-qualified variants; rebind wording becomes explicit settings change; parity rows are renamed consistently. + bun test scripts/e2e-v2.test.ts passes. + The host pin follows docs/host-updates.md: either fresh live-host evidence for 2.2.6 (hosts.json verified update plus harness constants) or a written residual note in the change summary explaining exactly why the pin update is not verifiable in this environment; no fabricated hash. + + + bun test scripts/e2e-v2.test.ts + + + + Synchronize README and GRACE projections, run the full gates, commit. + T-001,T-002,T-003,T-004,T-005 + + README: the ModelRolesPlugin row, the role-resolution paragraph, the binding/rebind/--force//vv-rebind contract paragraph, and the startup-snapshot note are rewritten for session-owned settings including the new /vv-settings and `vvoc session set` commands. + .grace/graph/main.xml and .grace/verification/main.xml describe the settings resolver, the deleted machinery, the new command surfaces, and the rewritten scenarios. + bun run check, bun run build, grace lint --path ., grace status --path . all pass with fresh output; git status contains only intended files; work committed as conventional commits on the task branch without pushing. + + + bun run check + bun run build + grace lint --path . + grace status --path . + + + + + diff --git a/.grace/changes/archive/C-SESSION-OWNED-SETTINGS/spec.xml b/.grace/changes/archive/C-SESSION-OWNED-SETTINGS/spec.xml new file mode 100644 index 00000000..1efc2912 --- /dev/null +++ b/.grace/changes/archive/C-SESSION-OWNED-SETTINGS/spec.xml @@ -0,0 +1,77 @@ + + + Replace the family snapshot-plus-guard subsystem with session-owned settings stored as native session metadata: the first request of a family root lazily records one resolved settings record under the metadata key `vvoc:settings`, every member of that family resolves behavior (roles, orchestration profile, plugin toggles) from the root's record until an explicit change command rewrites it, new families and explicitly updated families resolve the current effective vvoc configuration, and the entire binding-store, snapshot, guard, rebind-marker, and garbage-collection machinery is deleted with its consumers moved onto one family-settings resolver. + The snapshot subsystem freezes the effective configuration into a content-addressed snapshot at a family's first workload and then proves, on every dispatch, that reality still matches that frozen copy (family-qualified model variants, first-work admission tolerance, explicit-selection tracking, plugin-switch bookkeeping, rebind markers). Each special case is a future bug: September-November 2026 recorded about 39 runtime incidents ("Resolved ... does not match the captured family selection", "Snapshot state for family ... is invalid", "Refusing unbound ... dispatch") plus a host-upgrade incident that broke session startup. The root cause is structural: a second source of truth that must be continuously reconciled with reality. + + Store one narrow resolved settings record on the family ROOT session: metadata key `vvoc:settings`, value `{version:1, preset?, roles, profile, plugins}` where roles maps role ids to model selection strings and plugins maps plugin names to booleans, all with resolved values (never preset names for the governed fields, never credential values). + Write records only through read-modify-write: read the full metadata map, merge only the `vvoc:settings` key, write the whole map back, so foreign metadata keys always survive. + Resolve per request by walking the parentID/forkSessionID lineage to the family root and reading its `vvoc:settings`; a root without a record resolves the current effective vvoc configuration. Children and auxiliary children never carry their own copy. + Give every settings-consuming plugin one resolver surface (family id, resolved settings, projected effective configuration with live credentials) so orchestration profile, plugin toggles, workflow family policy, model roles for children, Guardian, and context-inspection stay behavior-compatible without a guard. + Keep the family root's model fully native: vvoc never switches or validates it again. Roles apply to children and auxiliary children at creation (auxiliary import payload model; one-shot creation reconciliation for host-created children). The model-roles toggle remains the master switch for applying role overrides at creation. + Provide explicit change commands that rewrite only the named family's root metadata: TUI `/vv-settings` replacing `/vv-rebind`, and CLI `vvoc session set --session <id>` with preset/profile/role/plugin actions and a pull-current-defaults action. Remove the `--force` flags from preset/role/plugin-toggle commands together with the rebind marker mechanism. + Migrate exactly once on the first start of the new version: existing family bindings convert into root metadata records for live roots, then the bindings/snapshots/rebind directories are deleted; a failed conversion keeps the directories and retries on the next start; a completed migration is a no-op. + + + The metadata write semantics are REPLACE of the whole map, so every write goes through the RMW helper and no other vvoc code path may call session update with metadata without reading first. + The settings record never carries credential values; credentials (web apiKeys, secrets-redaction seed) keep coming from the live effective configuration at every resolution. + Resolution failure modes are fail-soft: an undecodable or missing record falls back to the current effective configuration with a bounded diagnostic; lineage cycles or depth overruns fail the resolution, never the session. + Family plugin toggle resolution keeps the resolved boolean only; schedule/mode detail inside object-valued plugin entries always comes from the live configuration. + Orchestration, work items, result protocol, presets, and per-model tool routing keep their behavior; only the resolution source changes. + The context-inspection RPC keeps its wire DTO; the server-side policy source becomes the projected family settings view with content-derived settings identity and the same value-free error codes and fail-soft behavior. + Every touched source file keeps its GRACE module contract, map, and change-summary anchors accurate. + + + No GUI settings panels. + No change to preset/role CLI semantics beyond removing `--force` and the rebind plumbing. + No change to workflow protocol, work items, or the tracked result protocol. + No Telegram bridge behavior change. + No build-pin dependency updates; the e2e host pin discussion is limited to the harness acceptance version. + + + AC-01: A family whose root carries preset X settings keeps resolving X (orchestration policy text, child role models) after the project configuration changes to Y; a brand-new session resolves Y. Covered by the rewritten workflow integration test and the new session-settings tests. + AC-02: Re-acquiring the runtime against the same persisted sessions resolves the same settings; a fresh resolution after restart equals the pre-restart resolution (metadata persistence in the host database). + AC-03: `/vv-settings` and `vvoc session set` change only the named family's root metadata; another family and a fresh session resolve unchanged values. + AC-04: Guard code no longer exists: grep for SnapshotAdmissionError|SnapshotUnboundError|SnapshotStoreError across src/ is empty, as is grep for requestRebind|REBIND_COMMAND_ID|firstWorkPending. + AC-05: Migration converts existing fixture bindings into root metadata exactly once; a second start performs no work and the legacy directories are gone; a failed write keeps the directories. + AC-06: Read-modify-write preserves foreign metadata keys (a user key written before birth and before an explicit change survives both). + AC-07: The e2e-v2 harness binding/rebind parity rows and strings are rewritten for session settings and the harness unit tests pass; the host pin question is resolved by live evidence for 2.2.6 or an explicit residual note with exact reasoning. + AC-08: bun run check, bun run build, grace lint --path ., grace status --path ., and the relevant targeted tests pass with fresh output. + + + + + + + + + + + + + + + + + + + + + + + + bun test src/runtime/session-settings.test.ts src/runtime/context.test.ts + bun test src/plugins/workflow.integration.test.ts src/plugins/model-roles.integration.test.ts src/commands + bun test scripts/e2e-v2.test.ts + bun run check + bun run build + grace lint --path . + grace status --path . + Fresh deterministic tests for lazy birth, child resolution through the root, explicit-change isolation, RMW foreign-key preservation, migration one-shot with directory cleanup, and restart parity; fresh grep output proving the guard vocabulary is gone; fresh full-gate output; an explicit residual note when the 2.2.6 host pin cannot be verified live in this environment. + + + The native host writes arbitrary JSON session metadata through `session.update`/PATCH /api/session/{id} and persists it (verified on live OpenCode 2.2.6 by the package owner); the seam `NativeSnapshotContext.session.update` is widened to carry metadata and stays provable against the pinned host types. + The approved factory task constitutes the owner's explicit authorization to move this bundle to approved and execute it; recorded here per the GRACE approval provenance rule. + The pinned @opencode/* dependencies at 2.2.5 expose the same metadata session-update surface as the live 2.2.6 host the owner verified; the compile-time seam proof is the in-repo evidence for this. + + + diff --git a/.grace/graph/main.xml b/.grace/graph/main.xml index 46302736..c11e899c 100644 --- a/.grace/graph/main.xml +++ b/.grace/graph/main.xml @@ -2,8 +2,8 @@ @osovv/vv-opencode Portable OpenCode workflow package with server and TUI plugins, a Bun CLI, semantic model roles, unified web tools, context inspection, hashline editing, explicit work items, and managed agent workflows. - NativeRuntimeCORE_LOGICimplementedOwn the shared native OpenCode 2.0.18 runtime services every server plugin consumes: one lifecycle-managed runtime, snapshot service, and centralized native snapshot runtime acquired per exact plugin-context identity and released through idempotent leases; an authenticated same-instance full client discovered only through native service registration with plugin-instance RPC challenge; resource-specific permission evaluation awaited before side effects; the content-addressed, credential-free family binding store with per-family pointers, fail-soft rehydration, explicit force rebind, and reference-counted cleanup; snapshot-bound configuration, model selection, and real-model variant materialization in the model registry; snapshot-bound auxiliary generation; cross-context app/location-identity coordination; the read-only versioned context-inspection RPC projecting the registered tool catalog and allowlisted policy for the TUI; and the typed native contract seams. No V1 facade, no global configuration singleton, no service ensure/stop, no forced location reload.src/runtime/context.ts, src/runtime/snapshots.ts, src/runtime/snapshot-store.ts, src/runtime/snapshot-config.ts, src/runtime/model-registry.ts, src/runtime/auxiliary.ts, src/runtime/client.ts, src/runtime/permissions.ts, src/runtime/types.ts, src/runtime/coordination.ts, src/runtime/context-inspection.ts, src/runtime/context-inspection-contract.ts, src/runtime/context.test.ts, src/runtime/snapshots.test.ts, src/runtime/snapshot-store.test.ts, src/runtime/snapshot-config.test.ts, src/runtime/model-registry.test.ts, src/runtime/auxiliary.test.ts, src/runtime/client.test.ts, src/runtime/permissions.test.ts, src/runtime/coordination.test.ts, src/runtime/context-inspection.test.ts@opencode/plugin, @opencode/client, @opencode/schema, src/lib/config-layers.ts, src/lib/peak-hours.tsV-M-NATIVE-RUNTIME - E2eV2HarnessSCRIPTimplementedRun the installed-package real-host acceptance harness against the pinned native OpenCode 2.0.18 binary: a mode-aware CLI (list, core, full, tui), pinned binary/source/tarball hash verification, isolated HOME/XDG/scratch environments with an exact live-handle process registry and loopback-only provider dispatch, the owned fixture plugin/provider and parity matrix, packed-artifact import and mandatory-guard verification, the installed-artifact full runner that fails while any mandatory parity row is unverified and writes machine-readable parity evidence, and the real-PTY TUI tier. Core, compatibility, and parity evidence documents are observed outputs, never hand-authored.scripts/e2e-v2.ts, scripts/e2e-v2.test.ts, scripts/e2e-v2/cases.ts, scripts/e2e-v2/core.ts, scripts/e2e-v2/fixtures/plugin.ts, scripts/e2e-v2/full-cases.ts, scripts/e2e-v2/full.ts, scripts/e2e-v2/host.ts, scripts/e2e-v2/provider.ts, scripts/e2e-v2/tui.tsbun test, node:fs, node:path, native OpenCode 2.0.18 host binaryV-M-E2E-V2-HARNESS + NativeRuntimeCORE_LOGICimplementedOwn the shared native OpenCode runtime services every server plugin consumes: one lifecycle-managed runtime acquired per exact plugin-context identity and released through idempotent leases; an authenticated same-instance full client discovered only through native service registration with plugin-instance RPC challenge; resource-specific permission evaluation awaited before side effects; the session-owned family-settings service (narrow resolved settings record in root-session metadata with strict decode, lazy birth from the effective configuration, host-verified lineage resolution, read-modify-write metadata merging, projected consumer views with live credentials, and the one-shot legacy-binding migration); creation-level role application through agent transforms plus a one-shot host-created-child reconciliation; settings-bound auxiliary generation; cross-context app/location-identity coordination; the read-only versioned context-inspection RPC projecting the registered tool catalog and allowlisted family policy for the TUI; and the typed native contract seams. No snapshot store, no bind/guard machinery, no second settings source of truth, no V1 facade, no global configuration singleton, no service ensure/stop, no forced location reload.src/runtime/context.ts, src/runtime/session-settings.ts, src/runtime/snapshot-config.ts, src/runtime/model-registry.ts, src/runtime/auxiliary.ts, src/runtime/client.ts, src/runtime/permissions.ts, src/runtime/types.ts, src/runtime/coordination.ts, src/runtime/context-inspection.ts, src/runtime/context-inspection-contract.ts, src/runtime/context.test.ts, src/runtime/session-settings.test.ts, src/runtime/snapshot-config.test.ts, src/runtime/model-registry.test.ts, src/runtime/auxiliary.test.ts, src/runtime/client.test.ts, src/runtime/permissions.test.ts, src/runtime/coordination.test.ts, src/runtime/context-inspection.test.ts@opencode/plugin, @opencode/client, @opencode/schema, src/lib/config-layers.ts, src/lib/peak-hours.tsV-M-NATIVE-RUNTIME + E2eV2HarnessSCRIPTimplementedRun the installed-package real-host acceptance harness against the newest verified native OpenCode host binary: a mode-aware CLI (list, core, full, tui), pinned binary/source/tarball hash verification, isolated HOME/XDG/scratch environments with an exact live-handle process registry and loopback-only provider dispatch, the owned fixture plugin/provider and parity matrix, packed-artifact import and mandatory-guard verification, the installed-artifact full runner that fails while any mandatory parity row is unverified and writes machine-readable parity evidence, and the real-PTY TUI tier. Core, compatibility, and parity evidence documents are observed outputs, never hand-authored.scripts/e2e-v2.ts, scripts/e2e-v2.test.ts, scripts/e2e-v2/cases.ts, scripts/e2e-v2/core.ts, scripts/e2e-v2/fixtures/plugin.ts, scripts/e2e-v2/full-cases.ts, scripts/e2e-v2/full.ts, scripts/e2e-v2/host.ts, scripts/e2e-v2/provider.ts, scripts/e2e-v2/tui.tsbun test, node:fs, node:path, verified native OpenCode host binary (newest verified entry in src/hosts.json)V-M-E2E-V2-HARNESS ModelRolesCORE_LOGICimplementedDefine built-in role IDs, vv-role:* syntax, canonical provider/model#variant selection parsing, role normalization, resolution helpers, and built-in agent role bindings.src/lib/model-roles.tsnoneV-M-MODEL-ROLES ConfigLayersUTILITYimplementedResolve vvoc and native OpenCode runtime config sources across the VVOC_CONFIG/OPENCODE_CONFIG/OPENCODE_CONFIG_DIR env overrides, nearest project layer, global layer, and defaults, with project/global write targets, singleton runtime vvoc loading, strict native modelIntent intent extraction from the vvoc plugins entry, and source metadata; the dedicated V1 TUI config layer is removed.src/lib/config-layers.ts, src/lib/vvoc-paths.tsnoneV-M-CONFIG-LAYERS HostCompatCORE_LOGICimplementedOwn the supported native OpenCode host window, the verified-host evidence list from src/hosts.json, and the verified/tolerated/unsupported classification consumed by runtime discovery, CLI inspection, and the live host gates; report upstream drift and fetch an exact host binary for the gates.src/lib/host-compat.ts, src/hosts.json, scripts/host-check.tsnoneV-M-HOST-COMPAT @@ -14,17 +14,17 @@ AgentModelsUTILITYimplementedDefine supported vvoc model target IDs and shared model override validation helpers retained for current command and config compatibility.src/lib/agent-models.tsM-CLI-MANAGED-AGENTSV-M-CLI-AGENT-MODELS PluginToggleConfigUTILITYimplementedDefine vvoc-managed server and TUI plugin toggle names, default all-true config, default hashline edit-routing table, default tool-history-compaction entry (including the recent-message window and saved-output keys), revision-dated default peak-hours schedules and mode/grace entry, default spec-guard warn entry, the systemone provider toggle, conservative plugin entry materialization for sync/init, and pure isPluginEnabled helper.src/lib/plugin-toggle-config.tsnoneV-M-PLUGIN-TOGGLE-CONFIG EnvSubstitutionUTILITYimplementedSubstitute ${VAR} placeholders in config string values from the process environment and report referenced variables that are unset or empty by name without values.src/lib/env-substitution.tsnoneV-M-ENV-SUBSTITUTION - VVOCCommandsENTRY_POINTimplementedCompose the vvoc CLI and expose init, install, sync, launch, status, doctor, version, role, preset, plugin, config, patch-provider, completion, guardian config, lint, orchestration, analytics, telegram status and log, and upgrade commands, including cache-backed spec-package linting, managed native registration, exact native host-window compatibility diagnostics, read-only managed prompt/skill drift, duplicate skills-root, and deprecated permission-action warnings, and Telegram bridge diagnosis that reads the plugin config, probes the token, and inspects durable gateway state.src/cli.ts, src/commands/*.tsM-CONFIG-LAYERS, M-CLI-CONFIG, M-CLI-GUARDIANV-M-CLI-COMMANDS + VVOCCommandsENTRY_POINTimplementedCompose the vvoc CLI and expose init, install, sync, launch, status, doctor, version, role, preset, session, plugin, config, patch-provider, completion, guardian config, lint, orchestration, analytics, telegram status and log, and upgrade commands, including cache-backed spec-package linting, managed native registration, exact native host-window compatibility diagnostics, read-only managed prompt/skill drift, duplicate skills-root, and deprecated permission-action warnings, and Telegram bridge diagnosis that reads the plugin config, probes the token, and inspects durable gateway state.src/cli.ts, src/commands/*.tsM-CONFIG-LAYERS, M-CLI-CONFIG, M-CLI-GUARDIANV-M-CLI-COMMANDS GuardianCommandENTRY_POINTimplementedPrint or write the canonical Guardian config section with scoped paths, strict positive-integer duration parsing, threshold normalization, and fail-closed existing-config validation.src/commands/guardian.tsM-CLI-CONFIGV-M-CLI-GUARDIAN - ModelRolesPluginINTEGRATIONimplementedEnable vvoc role overriding on the shared native snapshot runtime: role assignments from effective vvoc config plus the vvoc plugins modelIntent envelope (root model, smallModel, agents, commands) resolve to concrete provider/model#variant selections on snapshot candidates before dispatch, with role intent never written as a native model literal and unknown roles failing loudly.src/plugins/model-roles/index.tsM-NATIVE-RUNTIME, M-CONFIG-LAYERS, M-MODEL-ROLES, M-CLI-CONFIG, M-PLUGIN-TOGGLE-CONFIGV-M-PLUGIN-MODEL-ROLES - RoleCommandENTRY_POINTimplementedList, set, and unset semantic role assignments in selected vvoc config for built-in and custom roles; set and unset support --force with an optional --session rebind request.src/commands/role.ts, src/lib/model-roles.tsM-CONFIG-LAYERS, M-CLI-CONFIG, M-MODEL-ROLESV-M-CLI-ROLE - PresetCommandENTRY_POINTimplementedList, show, and atomically apply declarative role and orchestration presets without mutating OpenCode config; --force writes a project-scoped rebind request so active families adopt the new preset lazily.src/commands/preset.ts, src/lib/model-roles.ts, src/lib/vvoc-preset-registry.tsM-CONFIG-LAYERS, M-CLI-CONFIG, M-MODEL-ROLES, M-VVOC-PRESET-REGISTRY, M-ORCHESTRATION-PROFILESV-M-CLI-PRESET + ModelRolesPluginINTEGRATIONimplementedEnable vvoc role overriding on the shared native runtime: role assignments resolve from the family's session-owned settings (root-session metadata record, live configuration as fallback) and apply at creation through agent-definition transforms plus a one-shot host-created-child reconciliation, with role intent never written as a native model literal and unknown roles failing loudly; the toggle never disables family-settings resolution.src/plugins/model-roles/index.tsM-NATIVE-RUNTIME, M-CONFIG-LAYERS, M-MODEL-ROLES, M-CLI-CONFIG, M-PLUGIN-TOGGLE-CONFIGV-M-PLUGIN-MODEL-ROLES + RoleCommandENTRY_POINTimplementedList, set, and unset semantic role assignments in selected vvoc config for built-in and custom roles; changes take effect for newly created sessions while running families keep their recorded settings until an explicit session-settings change.src/commands/role.ts, src/lib/model-roles.tsM-CONFIG-LAYERS, M-CLI-CONFIG, M-MODEL-ROLESV-M-CLI-ROLE + PresetCommandENTRY_POINTimplementedList, show, and atomically apply declarative role and orchestration presets without mutating OpenCode config; changes take effect for newly born families while running families keep their recorded settings until an explicit session-settings change.src/commands/preset.ts, src/lib/model-roles.ts, src/lib/vvoc-preset-registry.tsM-CONFIG-LAYERS, M-CLI-CONFIG, M-MODEL-ROLES, M-VVOC-PRESET-REGISTRY, M-ORCHESTRATION-PROFILESV-M-CLI-PRESET VvocPresetRegistryUTILITYimplementedDefine canonical managed preset role and orchestration definitions for config seeding, sync refresh, and completion, shipped as vv-codex, vv-zai, vv-deepseek, vv-alibaba, vv-osovv-ds, vv-osovv-mimo, vv-osovv-zai, vv-osovv-qwen, vv-astra-solo, and vv-astra-workers with canonical provider/model#variant role values, with the former vv-osovv-sol/flash/kimi names retained only as preserved saved user presets.src/lib/vvoc-preset-registry.tsM-ORCHESTRATION-PROFILESV-M-VVOC-PRESET-REGISTRY - GuardianPluginINTEGRATIONimplementedReview native OpenCode 2.0.18 permission evaluations through ctx.permission.hook("evaluate") with a constrained, snapshot-bound Guardian auxiliary model whose policy denies both built-in and canonical web tools, or with an opt-in provider-neutral System One decision provider when guardian.decisionBackend selects it and the systemone section, toggle, and ${VAR} key resolve; systemone answers (a low-risk noul and a risk score) map onto the same bounded low-risk verdict, and any provider failure defers; mutating effect only to auto-approve a bounded low-risk verdict and otherwise leaving the user’s manual or denied decision intact; unbound or disabled families defer.src/plugins/guardian/index.ts, templates/agents/guardian.mdM-NATIVE-RUNTIME, M-CONFIG-LAYERS, M-CLI-CONFIG, M-MODEL-ROLES, M-SYSTEMONE-PROVIDER, M-PLUGIN-TOGGLE-CONFIGV-M-PLUGIN-GUARDIAN + GuardianPluginINTEGRATIONimplementedReview native OpenCode 2.0.18 permission evaluations through ctx.permission.hook("evaluate") with a constrained, settings-bound Guardian auxiliary model whose policy denies both built-in and canonical web tools, or with an opt-in provider-neutral System One decision provider when guardian.decisionBackend selects it and the systemone section, toggle, and ${VAR} key resolve; systemone answers (a low-risk noul and a risk score) map onto the same bounded low-risk verdict, and any provider failure defers; mutating effect only to auto-approve a bounded low-risk verdict and otherwise leaving the user’s manual or denied decision intact; unbound or disabled families defer.src/plugins/guardian/index.ts, templates/agents/guardian.mdM-NATIVE-RUNTIME, M-CONFIG-LAYERS, M-CLI-CONFIG, M-MODEL-ROLES, M-SYSTEMONE-PROVIDER, M-PLUGIN-TOGGLE-CONFIGV-M-PLUGIN-GUARDIAN SystemOneProviderCORE_LOGICimplementedProvide a provider-neutral client for the de-facto System One decision protocol (POST /v1/systemone): shape state and typed noul, choice, and score questions, decode typed answers with probabilities and confidence while ignoring unknown fields, apply a bounded timeout and retry count, and raise credential-safe typed errors; any compliant endpoint is selected by baseUrl and model with no vendor-specific branch and no default endpoint.src/lib/systemone.tsnoneV-M-SYSTEMONE-PROVIDER - HashlineEditPluginINTEGRATIONimplementedRoute per-model edit tooling by registering hashline_edit and dsh str_replace_editor as native OpenCode 2.0.18 tools; resolve the session edit mode from the captured vvoc routing config (apply_patch | edit | str_replace_editor | hashline_edit); expose exactly one plugin edit tool per actual session model while never touching the host edit/apply_patch contracts; require a native resource permission before any write; transform native read results with true line/hash/context anchors and freshness tracking; apply valid payloads literally; honor an explicit host permission denial and restore plugin-owned versus captured host tool definitions by ownership; and report bounded post-edit diffs with apply warnings and editMode telemetry.src/plugins/hashline-edit/*.tsM-NATIVE-RUNTIME, M-PLUGIN-TOGGLE-CONFIG, M-CONFIG-LAYERS, M-CLI-CONFIG, M-AGENT-TOOL-CONTRACTV-M-PLUGIN-HASHLINE-EDIT - SystemContextInjectionPluginINTEGRATIONimplementedInject universal primary guidance — including the correctness-obligations block for behavior changes with material-assumption discipline, settled-conclusion reopen triggers, false-premise handling, pressure-versus-evidence distinction, test results treated as evidence rather than the specification, repository-answerable question resolution, and honest residual uncertainty, without mandatory per-claim reasoning labels or restatement rituals — and one bound-family-resolved orchestration policy into vv-controller at the request tail while static guidance stays in the system prefix; skill discovery is registered by install/sync through the native skills config, not by this plugin.src/plugins/system-context-injection/index.tsM-NATIVE-RUNTIME, M-CONFIG-LAYERS, M-ORCHESTRATION-PROFILES, M-CLI-MANAGED-AGENTS, M-PLUGIN-TOGGLE-CONFIGV-M-PLUGIN-SYSTEM-CONTEXT-INJECTION - SecretsRedactionPluginINTEGRATIONimplementedRedact secrets including placeholder-resolved configured web apiKey values in native provider-bound request hooks (context/generate/compaction/title) under the immutable family policy capture, then restore placeholders with copy-on-write semantics for frozen native tool inputs, SSE and non-SSE http.response bodies, and WebSocket frames — carrying placeholders split across SSE and WebSocket TEXT deltas with bounded carry while preserving opaque frames.src/plugins/secrets-redaction.ts, src/plugins/secrets-redaction/*.tsM-NATIVE-RUNTIME, M-CONFIG-LAYERS, M-CLI-CONFIG, M-PLUGIN-TOGGLE-CONFIG, M-ENV-SUBSTITUTIONV-M-PLUGIN-SECRETS-REDACTION + HashlineEditPluginINTEGRATIONimplementedRoute per-model edit tooling by registering hashline_edit and dsh str_replace_editor as native OpenCode 2.0.18 tools; resolve the session edit mode from the family-settings-projected vvoc routing config (apply_patch | edit | str_replace_editor | hashline_edit); expose exactly one plugin edit tool per actual session model while never touching the host edit/apply_patch contracts; require a native resource permission before any write; transform native read results with true line/hash/context anchors and freshness tracking; apply valid payloads literally; honor an explicit host permission denial and restore plugin-owned versus captured host tool definitions by ownership; and report bounded post-edit diffs with apply warnings and editMode telemetry.src/plugins/hashline-edit/*.tsM-NATIVE-RUNTIME, M-PLUGIN-TOGGLE-CONFIG, M-CONFIG-LAYERS, M-CLI-CONFIG, M-AGENT-TOOL-CONTRACTV-M-PLUGIN-HASHLINE-EDIT + SystemContextInjectionPluginINTEGRATIONimplementedInject universal primary guidance — including the correctness-obligations block for behavior changes with material-assumption discipline, settled-conclusion reopen triggers, false-premise handling, pressure-versus-evidence distinction, test results treated as evidence rather than the specification, repository-answerable question resolution, and honest residual uncertainty, without mandatory per-claim reasoning labels or restatement rituals — and one family-settings-resolved orchestration policy into vv-controller at the request tail while static guidance stays in the system prefix; skill discovery is registered by install/sync through the native skills config, not by this plugin.src/plugins/system-context-injection/index.tsM-NATIVE-RUNTIME, M-CONFIG-LAYERS, M-ORCHESTRATION-PROFILES, M-CLI-MANAGED-AGENTS, M-PLUGIN-TOGGLE-CONFIGV-M-PLUGIN-SYSTEM-CONTEXT-INJECTION + SecretsRedactionPluginINTEGRATIONimplementedRedact secrets including placeholder-resolved configured web apiKey values in native provider-bound request hooks (context/generate/compaction/title) under the family's resolved settings, then restore placeholders with copy-on-write semantics for frozen native tool inputs, SSE and non-SSE http.response bodies, and WebSocket frames — carrying placeholders split across SSE and WebSocket TEXT deltas with bounded carry while preserving opaque frames.src/plugins/secrets-redaction.ts, src/plugins/secrets-redaction/*.tsM-NATIVE-RUNTIME, M-CONFIG-LAYERS, M-CLI-CONFIG, M-PLUGIN-TOGGLE-CONFIG, M-ENV-SUBSTITUTIONV-M-PLUGIN-SECRETS-REDACTION SpecLintCORE_LOGICimplementedStrictly lint .vvoc spec-package XML artifacts through a content-addressed cache under XDG_CACHE_HOME keyed on every input hash plus a lint version: validate well-formedness over the htmlparser2 xmlMode tokenizer event stream, enforce attribute-free template contracts per artifact kind, component/task/wave/checkpoint identity patterns and uniqueness, bare-slug and task references, lifecycle-aware severity where draft incompleteness never blocks, the plan-components-subset-of-spec-components cross-file rule, the optional execution section with inline/classic/delegated modes, review_checkpoints with reviewer sets, scope paths, wave barriers, final coverage of tasks and write scopes, per-task write_scope requirements, and package layout checks; extractDelegatedPlanDefinition returns the typed obligations runtime registration reuses; the shared declared-path normalization and delegated obligation vocabulary are re-exported from M-WORKFLOW-CONTRACT.src/lib/spec-lint.ts, src/lib/spec-lint/parser.ts, src/lib/spec-lint/spec-rules.ts, src/lib/spec-lint/plan-rules.ts, src/lib/spec-lint/cross-file-rules.ts, src/lib/spec-lint/delegated-extraction.ts, src/lib/spec-lint/lint.ts, src/lib/spec-lint-cache.tshtmlparser2, M-CONFIG-LAYERSV-M-SPEC-LINT TelegramConfigResolverUTILITYimplementedResolve the optional canonical telegram section into gateway configuration: disabled-cause discrimination with value-free diagnostics, ${VAR} botToken resolution through the shared env-substitution helper with unset-reference reporting by name only, activity-window, maxTopics rotation-cap, and delivery-setting defaults, connectivity passthrough, and a stable credential-safe bot fingerprint for storage reset decisions.src/plugins/telegram/config.tssrc/lib/env-substitution.ts, src/lib/vvoc-config.tsV-M-TELEGRAM-CONFIG TelegramBotApiUTILITYimplementedOwn the narrow Telegram Bot API surface the bridge consumes through the vv-chat-shaped raw HTTP transport with an injectable call seam: topic create, edit, and delete (the only removal the Bot API supports in private chats); sends, edits, deletes, and inline markups; drafts with a permanent unsupported latch; callback answers; command registration; size-capped file download; and getUpdates polling reads, with credential-safe error normalization and mutually exclusive apiRoot or proxy connectivity.src/plugins/telegram/bot-api.tsnoneV-M-TELEGRAM-BOT-API @@ -33,12 +33,12 @@ TelegramGatewayINTEGRATIONimplementedBridge native sessions to Telegram topics and own the polling loop: structural event decoding without casting for session lifecycle, step, part-text, permission, question, and child-session events with per-event containment; top-level-only candidate reconciliation with immediate adoption of new sessions on creation, a resubscribing event pump with a periodic resync that purges evicted-thread delivery state; prompt, abort, and model-switch admission; streamed turns through the delivery layer; status transitions through throttled titles; bounded subagent cards; and the persisted-offset long-poll loop with owner allowlist, stale dropping, album grouping, and bounded backoff.src/plugins/telegram/sessions.ts, src/plugins/telegram/gateway.ts, src/plugins/telegram/commands.tsM-TELEGRAM-TOPICS, M-TELEGRAM-DELIVERY, M-TELEGRAM-BOT-APIV-M-TELEGRAM-GATEWAY TelegramBridgePluginINTEGRATIONimplementedAssemble the native vvoc.telegram server plugin: resolve the telegram section and toggle with value-free disabled diagnostics, run exactly one grammy gateway per process behind an app-identity singleton, wire topology, delivery, session bridge, interactions, commands, and the polling gateway over structural context and full-client seams, register owner commands, resurface pending permissions, and tear everything down in reverse on the last cleanup with partial-setup rollback; the entire bootstrap including native client acquisition runs as a supervised background task with bounded backoff retries after setup returns, because awaiting the client during setup deadlocks server boot, and every bootstrap await is abortable so cleanup resolves even when the client never becomes ready, and the assembly wires the maxTopics rotation cap and a bounded file log whose tail the CLI reads.src/plugins/telegram/index.ts, src/plugins/telegram/log.tsM-NATIVE-RUNTIME, M-TELEGRAM-GATEWAY, M-TELEGRAM-TOPICS, M-TELEGRAM-DELIVERY, M-TELEGRAM-BOT-API, M-TELEGRAM-CONFIG, M-PLUGIN-TOGGLE-CONFIG, M-CLI-CONFIG, @opencode/pluginV-M-PLUGIN-TELEGRAM-BRIDGE SpecGuardPluginINTEGRATIONimplementedAnnotate reads of active spec-package artifacts with cache-backed lint verdicts through tool.execute.after, validate post-write state with sibling spec cross-file inputs, refuse enforce-mode full-content writes that would leave ERROR findings through tool.execute.before, never annotate archived files, and degrade fail-open on any internal error.src/plugins/spec-guard/index.tsM-SPEC-LINT, M-CONFIG-LAYERS, M-PLUGIN-TOGGLE-CONFIGV-M-PLUGIN-SPEC-GUARD - ContextTuiPluginINTEGRATIONimplementedRegister /context and the /vv-rebind rebind-marker facade in the native OpenCode TUI, collect the selected session’s location-scoped state through the read-only versioned context-inspection RPC (registered tool catalog plus allowlisted family or current-runtime policy) together with provider-reported usage and active message history, combine measured truth with honest observable category estimates, classify canonical web tools as vvoc-managed, derive reconciled per-tool and per-MCP schema/history attribution and context-limit percentages, disclose schema catalogs the host cannot expose, render a bounded Overview/Tools/MCP dialog, and fail softly on missing or ambiguous state; the default TUI module also registers the analytics indicator, branding footer, and persistent peak-hours banner beside /context.src/tui.tsx, src/tui/context/*.ts, src/tui/context/*.tsxM-NATIVE-RUNTIME, M-CONFIG-LAYERS, M-CLI-CONFIG, M-PLUGIN-TOGGLE-CONFIG, M-TUI-PEAK-HOURS-BANNER, @opencode/plugin/tui, @opentui/keymap, @opentui/solid, solid-jsV-M-PLUGIN-CONTEXT-TUI - WebToolsPluginINTEGRATIONimplementedRegister web_search and web_fetch with closed provider-neutral input schemas and declared text/metadata/attachment result envelopes, reject unknown or invalid fields before dispatch, and select per bound family between the owned tools and the genuine native websearch/webfetch builtins through per-session tool visibility driven by the captured policy — never by mutating user permissions or unrelated host/MCP tools — while resolving provider config per session and reporting credential source metadata.src/plugins/web-tools/index.ts, src/plugins/web-tools/schemas.ts, src/plugins/web-tools/contracts.test.ts, src/plugins/web-tools.integration.test.tsM-NATIVE-RUNTIME, M-WEB-CONFIG, M-WEB-SEARCH-SERVICE, M-WEB-FETCH-SERVICE, M-CONFIG-LAYERS, M-PLUGIN-TOGGLE-CONFIG, M-AGENT-TOOL-CONTRACTV-M-PLUGIN-WEB-TOOLS - ToolHistoryCompactionPluginINTEGRATIONimplementedShrink the replayed conversation context non-destructively: through the native session context hook, deterministically rewrite only the in-memory tool part outputs the model is about to receive under the bound family’s captured policy — collapsing old read outputs to [Read <file>, lines X-Y] headers and head/marker/tail pruning over-budget ephemeral outputs (bash/grep/glob), while retaining knowledge outputs (web fetch/search, skill, subagent) — with an absolute recency-time recent-message window (protectRecentMessages, default 8) that never rewrites the working context, per-call protection that retained tools no longer consume, recoverable disk-backed pruning (savePrunedOutput with the full output path embedded in the marker), a min-savings guard, and strict vvoc config materialized on sync.src/plugins/tool-history-compaction/*.tsM-NATIVE-RUNTIME, M-PLUGIN-TOGGLE-CONFIG, M-CONFIG-LAYERS, M-CLI-CONFIGV-M-PLUGIN-TOOL-HISTORY-COMPACTION - AnalyticsPluginINTEGRATIONimplementedCollect per-step native token usage telemetry and session metadata into the local analytics store with vvoc and native host version attribution, gated per bound family by its immutable captured policy: native session step/compaction events feed usage records without fabricating zero tokens for unreported usage or double counting aggregate host events, and the disclosed native gap is that stateless session.generate emits no durable usage event.src/plugins/analytics/index.ts, src/plugins/analytics/index.test.ts, src/lib/analytics/store.ts, src/lib/analytics/metrics.ts, src/lib/analytics/types.tsM-NATIVE-RUNTIME, M-PLUGIN-TOGGLE-CONFIGV-M-PLUGIN-ANALYTICS + ContextTuiPluginINTEGRATIONimplementedRegister /context and the /vv-settings explicit family-settings command in the native OpenCode TUI, collect the selected session’s location-scoped state through the read-only versioned context-inspection RPC (registered tool catalog plus allowlisted family or current-runtime policy) together with provider-reported usage and active message history, combine measured truth with honest observable category estimates, classify canonical web tools as vvoc-managed, derive reconciled per-tool and per-MCP schema/history attribution and context-limit percentages, disclose schema catalogs the host cannot expose, render a bounded Overview/Tools/MCP dialog, and fail softly on missing or ambiguous state; the default TUI module also registers the analytics indicator, branding footer, and persistent peak-hours banner beside /context.src/tui.tsx, src/tui/settings/plugin.ts, src/tui/context/*.ts, src/tui/context/*.tsxM-NATIVE-RUNTIME, M-CONFIG-LAYERS, M-CLI-CONFIG, M-PLUGIN-TOGGLE-CONFIG, M-TUI-PEAK-HOURS-BANNER, @opencode/plugin/tui, @opentui/keymap, @opentui/solid, solid-jsV-M-PLUGIN-CONTEXT-TUI + WebToolsPluginINTEGRATIONimplementedRegister web_search and web_fetch with closed provider-neutral input schemas and declared text/metadata/attachment result envelopes, reject unknown or invalid fields before dispatch, and select per family between the owned tools and the genuine native websearch/webfetch builtins through per-session tool visibility driven by the family-settings projection — never by mutating user permissions or unrelated host/MCP tools — while resolving provider config per session and reporting credential source metadata.src/plugins/web-tools/index.ts, src/plugins/web-tools/schemas.ts, src/plugins/web-tools/contracts.test.ts, src/plugins/web-tools.integration.test.tsM-NATIVE-RUNTIME, M-WEB-CONFIG, M-WEB-SEARCH-SERVICE, M-WEB-FETCH-SERVICE, M-CONFIG-LAYERS, M-PLUGIN-TOGGLE-CONFIG, M-AGENT-TOOL-CONTRACTV-M-PLUGIN-WEB-TOOLS + ToolHistoryCompactionPluginINTEGRATIONimplementedShrink the replayed conversation context non-destructively: through the native session context hook, deterministically rewrite only the in-memory tool part outputs the model is about to receive under the family’s resolved settings — collapsing old read outputs to [Read <file>, lines X-Y] headers and head/marker/tail pruning over-budget ephemeral outputs (bash/grep/glob), while retaining knowledge outputs (web fetch/search, skill, subagent) — with an absolute recency-time recent-message window (protectRecentMessages, default 8) that never rewrites the working context, per-call protection that retained tools no longer consume, recoverable disk-backed pruning (savePrunedOutput with the full output path embedded in the marker), a min-savings guard, and strict vvoc config materialized on sync.src/plugins/tool-history-compaction/*.tsM-NATIVE-RUNTIME, M-PLUGIN-TOGGLE-CONFIG, M-CONFIG-LAYERS, M-CLI-CONFIGV-M-PLUGIN-TOOL-HISTORY-COMPACTION + AnalyticsPluginINTEGRATIONimplementedCollect per-step native token usage telemetry and session metadata into the local analytics store with vvoc and native host version attribution, gated per family by its resolved settings: native session step/compaction events feed usage records without fabricating zero tokens for unreported usage or double counting aggregate host events, and the disclosed native gap is that stateless session.generate emits no durable usage event.src/plugins/analytics/index.ts, src/plugins/analytics/index.test.ts, src/lib/analytics/store.ts, src/lib/analytics/metrics.ts, src/lib/analytics/types.tsM-NATIVE-RUNTIME, M-PLUGIN-TOGGLE-CONFIGV-M-PLUGIN-ANALYTICS PeakHoursSchedulesCORE_LOGICimplementedEvaluate provider-level peak-hour windows with an injectable clock: strict HH:MM window parsing with explicit timezones, optional weekday restrictions, and cross-midnight handling; provider id normalization with aliases; active-window lookup with boundary dates and remaining minutes; connected-provider suggestion filtering; bounded display formatting; and fail-open plugin entry parsing.src/lib/peak-hours.tsnoneV-M-PEAK-HOURS-SCHEDULES - PeakHoursPluginINTEGRATIONimplementedGate provider peak hours through the native session model.request hook after prompt admission: soft mode is pass-through with no server-side message mutation and peak cost state stays visible only through the TUI banner; hard mode throws before dispatch so the block renders as a standard message error part naming the provider, window end, wait time, and dynamically suggested connected off-peak providers; internal agents, subagent-like agents, parentID sessions, and sessions created before the active window start are never blocked; provider enumeration uses the config providers surface with authenticated native registry reads; failures degrade to soft or no-op.src/plugins/peak-hours/*.tsM-NATIVE-RUNTIME, M-PEAK-HOURS-SCHEDULES, M-CONFIG-LAYERS, M-PLUGIN-TOGGLE-CONFIG, M-CLI-MANAGED-AGENTSV-M-PLUGIN-PEAK-HOURS + PeakHoursPluginINTEGRATIONimplementedGate provider peak hours through the native session model.request hook: soft mode is pass-through with no server-side message mutation and peak cost state stays visible only through the TUI banner; hard mode throws before dispatch so the block renders as a standard message error part naming the provider, window end, wait time, and dynamically suggested connected off-peak providers; internal agents, subagent-like agents, parentID sessions, and sessions created before the active window start are never blocked; provider enumeration uses the config providers surface with authenticated native registry reads; failures degrade to soft or no-op.src/plugins/peak-hours/*.tsM-NATIVE-RUNTIME, M-PEAK-HOURS-SCHEDULES, M-CONFIG-LAYERS, M-PLUGIN-TOGGLE-CONFIG, M-CLI-MANAGED-AGENTSV-M-PLUGIN-PEAK-HOURS PeakHoursBannerINTEGRATIONimplementedRender a persistent orange app_bottom banner while the current model’s provider is in peak: resolve the current model from the open session with a config default fallback, compose the provider, window end, and off-peak provider suggestions, and fail softly on disabled entries or slot failures.src/tui/peak-hours/*.tsxM-PEAK-HOURS-SCHEDULES, M-CONFIG-LAYERS, M-PLUGIN-TOGGLE-CONFIG, @opencode/plugin/tui, @opentui/solid, @opentui/coreV-M-TUI-PEAK-HOURS-BANNER WebConfigResolverCORE_LOGICimplementedResolve optional web providers, fail-closed direct Z.AI regions, and environment-over-config credentials including ${VAR} placeholder substitution in config apiKey fields with value-free warnings for unset references from the immutable vvoc startup snapshot, and warn safely when a secret-bearing project config is tracked.src/plugins/web-tools/config.tsM-CLI-CONFIG, M-CONFIG-LAYERS, M-ENV-SUBSTITUTIONV-M-WEB-CONFIG WebHttpTransportUTILITYimplementedExecute bounded HTTP requests with timeout and abort propagation, hard response-size caps, redirect following, and transport error taxonomy.src/plugins/web-tools/http.tsnoneV-M-WEB-HTTP @@ -68,7 +68,7 @@ WorkflowAuthorityCORE_LOGICimplementedOwn bounded advance-execution authority and truthful stage provenance: observable root-user message eligibility, a shared three-unit recovery reserve with per-extension grants, replay and duplicate protection through session-wide message claims, immutable one-unit reserve debits bound to exact targets, fail-safe narrowing and revocation, changed-scope replay/extension strictness where a supplied scope may not silently replace or contradict the recorded scope while exact replay and finite extension remain supported, and controller-delegated versus user-observed stage approvals. Pure validation and proposed ledger transitions only; no SDK transport, dispatch, or persistence.src/plugins/workflow/authority.ts, src/plugins/workflow/authority.test.tsM-WORKFLOW-CONTRACTV-M-WORKFLOW-AUTHORITY ConfigValidateENTRY_POINTimplementedValidate canonical strict-current vvoc.json against schema v3 and semantic role/preset constraints.src/commands/config-validate.ts, src/commands/config.tsM-CLI-CONFIG, M-MODEL-ROLESV-M-CLI-CONFIG-VALIDATE PluginListENTRY_POINTimplementedDisplay OpenCode plugin entries with source path, enabled status, and vvoc plugin toggle status where available.src/commands/plugin-list.ts, src/commands/plugin.tsM-CLI-CONFIG, M-PLUGIN-TOGGLE-CONFIGV-M-CLI-PLUGIN-LIST - PluginToggleENTRY_POINTimplementedEnable or disable vvoc-managed plugins by mutating the plugins section in selected canonical vvoc config; --force with an optional --session writes a rebind request so active families adopt the new toggle lazily.src/commands/plugin-toggle.tsM-CLI-CONFIG, M-PLUGIN-TOGGLE-CONFIGV-M-CLI-PLUGIN-TOGGLE + PluginToggleENTRY_POINTimplementedEnable or disable vvoc-managed plugins by mutating the plugins section in selected canonical vvoc config; changes take effect for newly born families while running families keep their recorded settings until an explicit session-settings change.src/commands/plugin-toggle.tsM-CLI-CONFIG, M-PLUGIN-TOGGLE-CONFIGV-M-CLI-PLUGIN-TOGGLE PatchProviderENTRY_POINTimplementedApply provider-specific native OpenCode config patches — settings.baseURL and providers.<id>.models entries on real model ids with effort/thinking variants — without rewriting role-reference model fields, including the StepFun .ai baseURL with step-3.7-flash, the real gpt-5.5/gpt-5.6/gpt-6 models with codex effort variants (reasoning summary plus encrypted reasoning) and the legacy gpt-5.3-codex-spark medium variant that no shipped preset selects, DeepSeek flash models with 384000-token output limits and max/high variants, Alibaba qwen3.8-max#xhigh, Z.AI glm-5.3 and glm-5.3-flash variants, and the xiaomi mimo-v2.6-flash#thinking variant that enables thinking.type without a PDF declaration or reasoningEffort; `openai` is a compatibility alias for `codex` and `all` applies every registered preset, and no kimi provider patch remains.src/commands/patch-provider.tsM-CLI-CONFIGV-M-CLI-PATCH-PROVIDER CompletionENTRY_POINTimplementedInstall bash, zsh, and fish completions including role IDs, presets, orchestration, patch-provider presets, and nested commands.src/commands/completion.tsM-CLI-COMMANDS, M-MODEL-ROLES, M-VVOC-PRESET-REGISTRY, M-ORCHESTRATION-PROFILESV-M-CLI-COMPLETION InitENTRY_POINTimplementedInteractive or non-interactive project setup that registers the package, scaffolds managed prompts, seeds role assignments, and writes role-reference config defaults.src/commands/init.tsM-CLI-CONFIG, M-MODEL-ROLES, @clack/promptsV-M-CLI-INIT @@ -81,12 +81,12 @@ vvoc launch preflights the native host window, selects OpenCode runtime and vvoc config paths, pins OPENCODE_CONFIG and VVOC_CONFIG, conditionally pins the native OPENCODE_CONFIG_DIR discovery root, forwards passthrough args, and preserves child exit code. tui:local builds and injects the local TUI export through an isolated temporary config for pre-release smoke testing; release:bump creates and pushes an exact release commit, dispatches and waits for publish.yml, CI verifies all gates before npm publication, then the local authenticated process retries npm metadata, verifies gitHead, and creates the annotated tag plus GitHub Release. role list/set/unset loads selected vvoc config, validates role IDs, normalizes assignments, writes role map, or renders inventory. - The shared native snapshot runtime resolves vv-role:* assignments from the effective vvoc role map together with the vvoc plugins modelIntent envelope (model, smallModel, agents, commands), materializes concrete provider/model#variant selections on admission candidates before dispatch, replays capture-qualified variants for bound families, and fails loudly for unknown roles instead of falling back. + The shared native runtime resolves vv-role:* assignments from the family's session-owned settings record (root-session metadata, with the live effective vvoc role map plus the vvoc plugins modelIntent envelope as the birth-time and fallback source), applies concrete provider/model#variant selections at creation through agent definitions and a one-shot child reconciliation, never rewrites an existing session's model, and fails loudly for unknown roles instead of falling back. Preset command reads presets, validates selected name, normalizes role and orchestration assignments, applies declared fields atomically, and leaves OpenCode config untouched. Hashline plugin publishes closed edit input contracts, requests a native resource permission before any write, rewrites read output to line/hash anchors, rejects unknown, conflicting, or malformed fields before touching a file, validates current anchors on edit, applies batches, and rejects stale or malformed anchors while preserving literal empty-content deletion, null/empty-array deletion, boundary insertion, and `view_range` ending at `-1`. - Guardian evaluates native permission requests through ctx.permission.hook("evaluate"), builds bounded review input from the actual action, resources, source and host history, runs the snapshot-bound Guardian auxiliary generation, interprets the verdict, and mutates the effect to allow only when policy declares low risk, otherwise leaving the user’s ask or deny decision intact. + Guardian evaluates native permission requests through ctx.permission.hook("evaluate"), builds bounded review input from the actual action, resources, source and host history, runs the settings-bound Guardian auxiliary generation, interprets the verdict, and mutates the effect to allow only when policy declares low risk, otherwise leaving the user’s ask or deny decision intact. System context injection resolves agent identity, selects one concrete orchestration policy for vv-controller, and appends universal plus profile-specific guidance only to eligible agents. - SecretsRedactionPlugin resolves the immutable family policy before provider dispatch, redacts provider-bound payloads in the native request hooks, restores placeholders after completions, restores them copy-on-write for frozen native tool input before execution, and restores SSE plus WebSocket frames while preserving opaque frames. + SecretsRedactionPlugin resolves the family's settings before provider dispatch, redacts provider-bound payloads in the native request hooks, restores placeholders after completions, restores them copy-on-write for frozen native tool input before execution, and restores SSE plus WebSocket frames while preserving opaque frames. Plugin list scans OpenCode config and displays source plus vvoc toggle status when selected vvoc config is valid. Config validate resolves selected vvoc.json, parses strict JSON, validates schema v3 and semantic role/preset constraints. Completion detects shell, generates completion content, writes shell-specific files, and patches shell init files idempotently when required. @@ -105,8 +105,8 @@ work_checkpoint register binds an approved validated plan and spec by content hashes to the trusted workspace root and stable delegated work items, accepting only its supported approved native plan package; start captures a fresh scope fingerprint and opens exactly the declared reviewer set as a linked review_only item; verify derives passed, failed, stale, or stopped from recorded results plus recomputed fingerprints and approval hashes, gates dependent waves behind passed milestones, and seals completed runs only through the final checkpoint's explicit complete; recover settles a stopped generation as historical stopped evidence or grants at most one further generation per autonomous or replay-protected root-user-authorized unit without satisfying prerequisites, erasing FAIL history, or sealing the run. A controller registers one common execution from a validated native package, a provided-plan reference, or the current conversation; tasks and checkpoints are validated as an exact-reference acyclic graph inside the declared boundary before any record is published, later additions append without replaying accepted work, explicit replacement/split preserves aggregate criteria and scope while sharing the parent attempt budget, and a generic execution completes only when every active task is accepted and every assigned review barrier passed for the current revision. An explicitly delegated root-user instruction records a bounded advance authority with a shared three-unit recovery reserve; ordinary allowances are consumed before one recorded reserve unit authorizes one extra attempt or checkpoint generation, duplicate calls and replay cannot refill it, restart or replacement cannot mint authority, narrowing or revocation disables future credits without erasing history, and stage approvals keep truthful controller-delegated provenance while reserved stops still wait. - The server-side runtime registers a read-only versioned context-inspection RPC on the plugin context; the TUI /context collector calls it with the selected session, and the handler validates the serving location/project plus a meaningful family capture (or previews the already-loaded effective config), projects the registered tool catalog through ctx.tool.list with Schema-owned vendor schema conversion and an allowlisted policy projection (context, analytics, peak-hours) using value-free stable error codes; the TUI combines that result with native message history, model metadata and MCP status to render the bounded scrollable Overview/Tools/MCP dialog. - Preset or CLI writes a root profile, startup config resolves it once, and prompt plugins inject only the concrete vv-controller and workflow guidance for that profile. + The server-side runtime registers a read-only versioned context-inspection RPC on the plugin context; the TUI /context collector calls it with the selected session, and the handler validates the serving location/project plus a meaningful family settings view (or previews the already-loaded effective config), projects the registered tool catalog through ctx.tool.list with Schema-owned vendor schema conversion and an allowlisted policy projection (context, analytics, peak-hours) using value-free stable error codes; the TUI combines that result with native message history, model metadata and MCP status to render the bounded scrollable Overview/Tools/MCP dialog. + Preset or CLI writes a root profile, a family births or explicitly changes its recorded profile through session settings, and prompt plugins inject only the concrete vv-controller and workflow guidance for the resolved profile. web_search rejects unknown, invalid, or out-of-bounds arguments before any provider dispatch, applies canonical query and count defaults at execution time, requests permission before network use, validates startup-resolved credentials, dispatches Exa, Brave, or explicit-region Z.AI through bounded HTTP transport, normalizes provider-ranked results, and returns Markdown plus credential-safe metadata. web_fetch validates an HTTP or HTTPS URL and rejects unknown or invalid arguments before any provider dispatch, applies canonical format and timeout defaults at execution time, requests permission before network use, dispatches native, Spider, or explicit-region Z.AI retrieval through bounded transport, converts text or returns supported image/PDF attachments, and emits credential-safe structured metadata. diff --git a/.grace/verification/main.xml b/.grace/verification/main.xml index c9e0fecd..910f48f2 100644 --- a/.grace/verification/main.xml +++ b/.grace/verification/main.xml @@ -33,9 +33,9 @@ DF-WEB-SEARCHhighweb_search requests permission, applies count 8 when omitted while preserving explicit values, validates startup-resolved provider configuration, uses bounded transport, normalizes ranked results, and redacts credential-bearing failures. DF-WEB-FETCHhighweb_fetch requests permission, validates HTTP(S) input, applies canonical format and timeout defaults, uses bounded transport, returns supported text or attachment output, and redacts credential-bearing failures. - DF-ORCHESTRATION-PROFILE, DF-PRESET-SWITCH, DF-SYSTEM-CONTEXT, DF-WORKFLOW-GUIDANCEhighEffective startup config resolves one profile; vv-controller receives only its concrete policy; old v3 config defaults to balanced; preset writes are atomic; invalid explicit values fail without fallback. - src/runtime/context.test.tssrc/runtime/snapshots.test.tssrc/runtime/snapshot-store.test.tssrc/runtime/snapshot-config.test.tssrc/runtime/model-registry.test.tssrc/runtime/auxiliary.test.tssrc/runtime/client.test.tssrc/runtime/permissions.test.tssrc/runtime/coordination.test.tssrc/runtime/context-inspection.test.tsbun test src/runtimebun run typecheckThe shared native runtime acquires and releases per exact plugin context, authenticates the same-instance client only through native service registration with a plugin-instance RPC challenge, awaits resource-specific permissions before effects, binds each family once to a content-addressed, credential-free behaviour policy that rehydrates across restart, fails soft per family, and consumes explicit force-rebind requests, materializes capture-qualified model variants, coordinates app/location identity across contexts, and serves the read-only context-inspection RPC with allowlisted output and value-free error codes.The declared TestFiles and Checks exercise this scenario deterministically.Reference-counted acquisition with idempotent release never leaks credentials or stops the host; permission denial, disconnect, and cancellation fail closed with zero side effects; a missing, corrupt, or version-mismatched binding fails soft to the current configuration without failing plugin load or unrelated families; a new session takes the current configuration while an existing family stays frozen until an explicit force request; session deletion drops the pointer and unreferenced snapshots are collected; distinct families on one model keep distinct captured variants; and the RPC refuses unmeaningful captures, out-of-location sessions, and unconvertible or oversized schemas instead of exposing raw state. - scripts/e2e-v2.test.tsbun test scripts/e2e-v2.test.tsbun run typecheckThe harness CLI enforces its list/core/full/tui mode contract with bounded outputs and nonzero exits, refuses missing hosts and unverified full parity, and guards loopback-only dispatch, scratch ownership, environment allow-listing and redaction, packed-artifact equivalence, live-handle process ownership, parity-row gating, and evidence totals without hand-authored results.The declared TestFiles and Checks exercise this scenario deterministically.Argument parsing, default-full refusal, missing-host refusal, injected core exit codes, mandatory-row gating with residual reason/cross-reference validation, evidence summaries, scratch marker cleanup, env credential redaction, foreign/exited pid refusal, and fixture guard forwarding fail closed deterministically; the real packed-host, full-parity, and PTY TUI tiers run externally through bun run e2e:v2 against the pinned native OpenCode 2.0.18 binary. + DF-ORCHESTRATION-PROFILE, DF-PRESET-SWITCH, DF-SYSTEM-CONTEXT, DF-WORKFLOW-GUIDANCEhighThe resolved family or startup profile resolves one concrete policy; vv-controller receives only its concrete policy; old v3 config defaults to balanced; preset writes are atomic; invalid explicit values fail without fallback. + src/runtime/context.test.tssrc/runtime/session-settings.test.tssrc/runtime/snapshot-config.test.tssrc/runtime/model-registry.test.tssrc/runtime/auxiliary.test.tssrc/runtime/client.test.tssrc/runtime/permissions.test.tssrc/runtime/coordination.test.tssrc/runtime/context-inspection.test.tsbun test src/runtimebun run typecheckThe shared native runtime acquires and releases per exact plugin context, authenticates the same-instance client only through native service registration with a plugin-instance RPC challenge, awaits resource-specific permissions before effects, resolves each family through host-verified lineage to the root session’s metadata settings record (lazy birth from the effective configuration, strict decode with fail-soft fallback, read-modify-write merging that preserves foreign metadata keys), applies roles at creation through agent transforms plus a one-shot child reconciliation, migrates legacy bindings once with directory pruning only on full success, coordinates app/location identity across contexts, and serves the read-only context-inspection RPC with allowlisted output and value-free error codes.The declared TestFiles and Checks exercise this scenario deterministically.Reference-counted acquisition with idempotent release never leaks credentials or stops the host; permission denial, disconnect, and cancellation fail closed with zero side effects; invalid or unreadable settings fail soft to the live configuration with bounded diagnostics; a new family births the current configuration while an existing family keeps its recorded settings across restarts and config changes; birth is race-free under the per-family lock and cached per process; legacy migration converts live roots once, skips dead roots, keeps directories on write failure, and prunes only after a fully successful pass; and the RPC refuses unmeaningful settings views, out-of-location sessions, and unconvertible or oversized schemas instead of exposing raw state. + scripts/e2e-v2.test.tsbun test scripts/e2e-v2.test.tsbun run typecheckThe harness CLI enforces its list/core/full/tui mode contract with bounded outputs and nonzero exits, refuses missing hosts and unverified full parity, and guards loopback-only dispatch, scratch ownership, environment allow-listing and redaction, packed-artifact equivalence, live-handle process ownership, parity-row gating, and evidence totals without hand-authored results.The declared TestFiles and Checks exercise this scenario deterministically.Argument parsing, default-full refusal, missing-host refusal, injected core exit codes, mandatory-row gating with residual reason/cross-reference validation, evidence summaries, scratch marker cleanup, env credential redaction, foreign/exited pid refusal, and fixture guard forwarding fail closed deterministically; the real packed-host, full-parity, and PTY TUI tiers run externally through bun run e2e:v2 against the newest verified native OpenCode host binary (verified list in src/hosts.json). src/lib/model-roles.test.tsbun test src/lib/model-roles.test.tsbun x tsc --noEmitBuilt-in role IDs, references, concrete model selections, and invalid inputs resolve or fail deterministically.The declared TestFiles and Checks exercise this scenario deterministically. src/lib/config-layers.test.tsbun test src/lib/config-layers.test.tsbun run typecheckEnvironment, project, global, and default config layers resolve with the documented precedence and write boundaries.The declared TestFiles and Checks exercise this scenario deterministically.Global/project/effective vvoc and native OpenCode runtime sources honor the VVOC_CONFIG/OPENCODE_CONFIG/OPENCODE_CONFIG_DIR env precedence, json/jsonc selection, project-local write boundaries, and strict raw modelIntent envelope extraction with value-free validation errors. src/lib/host-compat.test.tsscripts/host-check.test.tsbun test src/lib/host-compat.test.ts scripts/host-check.test.tsbun run host:checkbun run contracts:host --probeThe supported host window is major-bounded (>=2.0.18 <3.0.0): 2.0.18 through 2.9.x are supported, prereleases and 1.x/3.x are refused, and the verified list from src/hosts.json separates verified from tolerated in-window hosts. The drift reporter classifies the newest stable upstream release and prints the next step, and the live probe authorizes exactly the selected host version with its SHA-256.The declared TestFiles and Checks exercise this scenario deterministically.Window parsing and classification are deterministic and data-driven; the verified list is the only evidence-bearing set; the drift report never claims a host is verified without an entry, and the live probe records schemaArgumentDescriptions and hostArgNormalization observations instead of asserting a host-specific forwarding shape. @@ -52,20 +52,20 @@ src/plugins/telegram/sessions.test.tssrc/plugins/telegram/commands.test.tssrc/plugins/telegram/gateway.test.tsbun test src/plugins/telegram/sessions.test.ts src/plugins/telegram/commands.test.ts src/plugins/telegram/gateway.test.tsbun run typecheckThe session bridge decodes the real V2 vocabulary — session, step, text-delta, inbox, tool, execution, permission, and form events — structurally, echoes admitted user prompts as quoted messages, contains every per-event failure, reconciles only top-level sessions and adopts a new one immediately while children never get a topic, resubscribes and resyncs after any stream end so a missed live event cannot leave a stale topic set, runs a periodic resync, admits prompts with files, aborts, and switches models through injectable native actions, streams turns into mirrored finals, renders bounded subagent cards without child topics, and answers manual permission and question prompts with the custom-answer input gate and truthful closure; the polling gateway advances the persisted offset only after dispatch, drops non-owner and stale updates, groups albums into one dispatch, and backs off boundedly before recovering.The declared TestFiles and Checks exercise this scenario deterministically with injected native, transport, store, and clock boundaries. src/plugins/telegram/index.test.tssrc/plugins/telegram.integration.test.tssrc/plugins/telegram/log.test.tsbun test src/plugins/telegram/index.test.ts src/plugins/telegram.integration.test.ts src/plugins/telegram/log.test.tsbun run checkbun run pack:checkThe assembled plugin stays dormant with value-free diagnostics for absent sections, unresolved tokens, and disabled toggles; two contexts sharing one app object construct exactly one transport and only the last cleanup releases the runtime; the end-to-end flow creates a session through the General project picker, admits a prompted topic message, completes a manual permission round trip, persists the offset, and after a simulated restart reuses the stored General and mappings without duplicates; a disabled toggle registers nothing; setup returns promptly and cleanup resolves even when runtime acquisition never settles, and a failing bootstrap attempt is contained, retried with backoff, and succeeds on a later attempt (boot-deadlock regression), and the plugin diagnostics append token-free lines to a size-bounded file log and read back as a bounded tail.The declared TestFiles and Checks exercise this scenario deterministically through the public plugin entry with injected boundaries and no live Telegram or native dependency. src/lib/env-substitution.test.tsbun test src/lib/env-substitution.test.tsbun run typecheckConfig string ${VAR} placeholders substitute from the process environment with unset or empty references reported by name.The declared TestFiles and Checks exercise this scenario deterministically.Literal values pass through unchanged, full and partial placeholders substitute, unset and empty variables substitute as empty strings and are reported by name without values, and duplicate references deduplicate. - src/commands/analytics.test.tssrc/commands/completion.test.tssrc/commands/config-validate.test.tssrc/commands/doctor.test.tssrc/commands/guardian.test.tssrc/commands/init.test.tssrc/commands/launch.test.tssrc/commands/lint.test.tssrc/commands/orchestration.test.tssrc/commands/patch-provider.test.tssrc/commands/plugin-list.test.tssrc/commands/plugin-toggle.test.tssrc/commands/preset.test.tssrc/commands/role.test.tssrc/commands/status.test.tssrc/commands/sync.test.tssrc/commands/telegram-status.test.tssrc/commands/upgrade.test.tsbun test src/commandsbun run lintbun run fmt:checkbun run buildThe public command tree and command implementations preserve documented scoped behavior, output, validation, and failure semantics, including strict Guardian duration parsing, the exact native host-window range reported by status and doctor, and the telegram status report that resolves the bridge config, reports a value-free disabled reason, probes the token, and inspects durable gateway state without printing the token; doctor additionally reports stale managed prompts/skills, duplicate skills roots, and deprecated permission actions as warnings while missing {file:} references remain non-zero problems.The declared TestFiles and Checks exercise this scenario deterministically. + src/commands/analytics.test.tssrc/commands/completion.test.tssrc/commands/config-validate.test.tssrc/commands/doctor.test.tssrc/commands/guardian.test.tssrc/commands/init.test.tssrc/commands/launch.test.tssrc/commands/lint.test.tssrc/commands/orchestration.test.tssrc/commands/patch-provider.test.tssrc/commands/plugin-list.test.tssrc/commands/plugin-toggle.test.tssrc/commands/preset.test.tssrc/commands/role.test.tssrc/commands/session.test.tssrc/commands/status.test.tssrc/commands/sync.test.tssrc/commands/telegram-status.test.tssrc/commands/upgrade.test.tsbun test src/commandsbun run lintbun run fmt:checkbun run buildThe public command tree and command implementations preserve documented scoped behavior, output, validation, and failure semantics, including strict Guardian duration parsing, the exact native host-window range reported by status and doctor, and the telegram status report that resolves the bridge config, reports a value-free disabled reason, probes the token, and inspects durable gateway state without printing the token; doctor additionally reports stale managed prompts/skills, duplicate skills roots, and deprecated permission actions as warnings while missing {file:} references remain non-zero problems.The declared TestFiles and Checks exercise this scenario deterministically. src/commands/guardian.test.tsbun test src/commands/guardian.test.tsbun run typecheckThe Guardian command prints or persists canonical scoped config, normalizes thresholds, and parses documented duration values strictly.The declared TestFiles and Checks exercise this scenario deterministically.Positive integer durations remain valid; zero, negative, fractional, malformed, and missing duration values fail; threshold order normalizes deterministically; existing invalid config is never silently repaired. - src/plugins/model-roles.integration.test.tsbun test src/plugins/model-roles.integration.test.tsbun x tsc --noEmitThe native Plugin.define export shares one snapshot runtime per context and drives vvoc role overriding through per-family content-addressed binding: policy capture, restart rehydration, explicit force rebind, agent-aware role selection from the vvoc role map plus modelIntent intent, initial explicit/implicit selection provenance, post-bind explicit choices, config.updated reconfiguration for unbound work, per-event error containment and recovery, auxiliary title children, capture-qualified variant materialization, all-kind unbound refusal, the one-shot pre-binding bare-base tolerance that admits a just-bound implicit first turn exactly once, and an optional isolated real OpenCode 2.0.18 host smoke loading the actual built plugin.The declared TestFiles and Checks exercise this scenario deterministically.Disabled override still captures policy while leaving agent models untouched; an enabled override selects the vv-controller role rather than the default; a bound family stays frozen across config updates while new families see current config; invalid and transient config events are contained and recovered; a moved worktree finds the root capture instead of re-binding; a resolved model that mismatches the capture fails closed, excepting exactly the first pre-binding bare-base dispatch of a just-bound implicit session and closing that tolerance on the first guarded dispatch, a user choice, or session deletion; and raw agent intent overrides the built-in role for a custom agent. - src/plugins/guardian.integration.test.tsbun test src/plugins/guardian.integration.test.tsbun x tsc --noEmitGuardian permission review remains bounded, role-resolved, web-tool constrained, backend-selectable, and fail-safe.The declared TestFiles and Checks exercise this scenario deterministically.Native permission.evaluate events mutate effect only within policy: unbound or disabled families defer, low-risk verdicts below the threshold auto-allow through snapshot-bound auxiliary generation or through the opt-in System One provider (a low-risk noul gated by lowRiskThreshold and a risk score mapped linearly onto the approval threshold); any provider failure or malformed answer, uncertainty, inference failure, and empty verdicts fall back to the user’s ask/deny decision; a disabled section, unresolved ${VAR} key, or disabled systemone toggle keeps the fast backend without auto-approving from the provider; review input stays bounded and credential-safe, provider failures never auto-approve, and setup registers the evaluate hook and releases the shared runtime on cleanup. + src/plugins/model-roles.integration.test.tsbun test src/plugins/model-roles.integration.test.tsbun x tsc --noEmitThe native Plugin.define export shares one runtime and settings service per context and drives session-owned family settings: lazy metadata birth on the first resolution with no per-request rewrite, foreign-metadata preservation, family-scoped explicit metadata changes, restart-stable resolution, config-watch containment and recovery, lineage failures resolving to nothing without failing the caller, creation-level role application through agent definitions and the one-shot host-created-child reconciliation, auxiliary title children bound to family role models, and an optional isolated real OpenCode host smoke loading the actual built plugin.The declared TestFiles and Checks exercise this scenario deterministically.Disabled override leaves agent models untouched while settings resolution continues; an enabled override selects the vv-controller role through native creation paths; a born family keeps its recorded settings and roles across config flips while a fresh family births the changed config; an explicit root metadata change moves only that family; invalid config events are contained and recovered when fixed; and creation reconciliation applies exactly once per child. + src/plugins/guardian.integration.test.tsbun test src/plugins/guardian.integration.test.tsbun x tsc --noEmitGuardian permission review remains bounded, role-resolved, web-tool constrained, backend-selectable, and fail-safe.The declared TestFiles and Checks exercise this scenario deterministically.Native permission.evaluate events mutate effect only within policy: unresolved or disabled families defer, low-risk verdicts below the threshold auto-allow through settings-bound auxiliary generation or through the opt-in System One provider (a low-risk noul gated by lowRiskThreshold and a risk score mapped linearly onto the approval threshold); any provider failure or malformed answer, uncertainty, inference failure, and empty verdicts fall back to the user’s ask/deny decision; a disabled section, unresolved ${VAR} key, or disabled systemone toggle keeps the fast backend without auto-approving from the provider; review input stays bounded and credential-safe, provider failures never auto-approve, and setup registers the evaluate hook and releases the shared runtime on cleanup. src/lib/systemone.test.tsbun test src/lib/systemone.test.tsbun x tsc --noEmitThe provider-neutral System One client shapes requests, decodes typed noul, choice, and score answers, and fails safely over an injected transport.The declared TestFiles and Checks exercise this scenario deterministically.POST {baseUrl}/v1/systemone receives state, model, and typed questions; noul, choice, and score answers decode with probabilities and confidence while unknown fields and provider extras are ignored; two distinct base URLs work through one client with no vendor branch; a bounded timeout and retry count apply and exhausted retries surface a typed error; 401 is not retried; invalid local request shapes are rejected before any call; and no error message contains the apiKey or the state. - src/plugins/hashline-edit/normalize-edits.test.tssrc/plugins/hashline-edit/contracts.test.tssrc/plugins/hashline-edit.integration.test.tssrc/plugins/hashline-edit/edit-operations.test.tssrc/plugins/hashline-edit/validation.test.tssrc/plugins/hashline-edit/tool-description.test.tssrc/plugins/hashline-edit/routing.test.tssrc/plugins/hashline-edit/session-state.test.tssrc/plugins/hashline-edit/str-replace-editor.test.tsbun test src/plugins/hashline-edit/normalize-edits.test.ts src/plugins/hashline-edit/contracts.test.ts src/plugins/hashline-edit.integration.test.ts src/plugins/hashline-edit/edit-operations.test.ts src/plugins/hashline-edit/validation.test.ts src/plugins/hashline-edit/tool-description.test.ts src/plugins/hashline-edit/routing.test.ts src/plugins/hashline-edit/session-state.test.ts src/plugins/hashline-edit/str-replace-editor.test.tsbun x tsc --noEmitThe four-value routing vocabulary (apply_patch | edit | str_replace_editor | hashline_edit) resolves per bound family and session model, and native session tool-list visibility leaves exactly one existing-file editor visible — the host built-in edit stays visible for the edit cohort and is hidden for the str_replace_editor/hashline_edit cohorts, apply_patch is left to the host gate, new-file creation stays available, and the plugin registers only hashline_edit and str_replace_editor as native tools while requiring an awaited native resource permission before any write, honoring an explicit host permission denial (a session that exposes no edit-family tool is left untouched without throwing), and restoring plugin-owned definitions from the plugin itself rather than from the captured host map; hashline_edit and str_replace_editor publish closed operation-specific input contracts, reject unknown or conflicting fields and malformed ranges before mutation, and preserve hashline read anchors, three-part reference enforcement, unified replace with optional end, replace_range, lines:null/empty deletion, append/prepend boundary insertion, meaningful empty new_str, a view_range ending at -1, literal payload application, neighbor-duplication warnings, the dsh str_replace_editor contract, and bounded post-edit diff feedback. Post-release telemetry reruns the opencode.db baseline split by the editMode metadata tag.The declared TestFiles and Checks exercise this scenario deterministically. + src/plugins/hashline-edit/normalize-edits.test.tssrc/plugins/hashline-edit/contracts.test.tssrc/plugins/hashline-edit.integration.test.tssrc/plugins/hashline-edit/edit-operations.test.tssrc/plugins/hashline-edit/validation.test.tssrc/plugins/hashline-edit/tool-description.test.tssrc/plugins/hashline-edit/routing.test.tssrc/plugins/hashline-edit/session-state.test.tssrc/plugins/hashline-edit/str-replace-editor.test.tsbun test src/plugins/hashline-edit/normalize-edits.test.ts src/plugins/hashline-edit/contracts.test.ts src/plugins/hashline-edit.integration.test.ts src/plugins/hashline-edit/edit-operations.test.ts src/plugins/hashline-edit/validation.test.ts src/plugins/hashline-edit/tool-description.test.ts src/plugins/hashline-edit/routing.test.ts src/plugins/hashline-edit/session-state.test.ts src/plugins/hashline-edit/str-replace-editor.test.tsbun x tsc --noEmitThe four-value routing vocabulary (apply_patch | edit | str_replace_editor | hashline_edit) resolves per family settings and session model, and native session tool-list visibility leaves exactly one existing-file editor visible — the host built-in edit stays visible for the edit cohort and is hidden for the str_replace_editor/hashline_edit cohorts, apply_patch is left to the host gate, new-file creation stays available, and the plugin registers only hashline_edit and str_replace_editor as native tools while requiring an awaited native resource permission before any write, honoring an explicit host permission denial (a session that exposes no edit-family tool is left untouched without throwing), and restoring plugin-owned definitions from the plugin itself rather than from the captured host map; hashline_edit and str_replace_editor publish closed operation-specific input contracts, reject unknown or conflicting fields and malformed ranges before mutation, and preserve hashline read anchors, three-part reference enforcement, unified replace with optional end, replace_range, lines:null/empty deletion, append/prepend boundary insertion, meaningful empty new_str, a view_range ending at -1, literal payload application, neighbor-duplication warnings, the dsh str_replace_editor contract, and bounded post-edit diff feedback. Post-release telemetry reruns the opencode.db baseline split by the editMode metadata tag.The declared TestFiles and Checks exercise this scenario deterministically. src/plugins/system-context-injection.integration.test.tsbun test src/plugins/system-context-injection.integration.test.tsbun x tsc --noEmitEligible primary sessions receive universal guidance — including the correctness-obligations block for behavior changes with material-assumption discipline, settled-conclusion reopen triggers, false-premise handling, pressure-versus-evidence distinction, test results treated as evidence rather than the specification, repository-answerable question resolution, and honest residual uncertainty, without mandatory per-claim reasoning labels or restatement rituals — and the controller orchestration policy injected at the request tail while static guidance stays in the system prefix; tests assert deterministic instruction delivery, not model behavior.The declared TestFiles and Checks exercise this scenario deterministically. - src/plugins/secrets-redaction/engine.test.tssrc/plugins/secrets-redaction/index.test.tssrc/plugins/secrets-redaction/patterns.test.tssrc/plugins/secrets-redaction/session.test.tssrc/plugins/secrets-redaction/stream.test.tsbun test src/plugins/secrets-redaction/engine.test.ts src/plugins/secrets-redaction/index.test.ts src/plugins/secrets-redaction/patterns.test.ts src/plugins/secrets-redaction/session.test.ts src/plugins/secrets-redaction/stream.test.tsbun run typecheckbun run lintBuilt-in, custom, and configured secrets redact before provider exposure under the immutable family policy, restore copy-on-write for frozen native tool input and streamed provider responses — including placeholders split across SSE and WebSocket TEXT deltas with bounded lane carry — and preserve opaque frames.The declared TestFiles and Checks exercise this scenario deterministically.Built-in, regex, custom keyword, and configured web apiKey values redact before provider calls, with ${VAR} placeholders in web apiKey fields resolved from the environment so redaction covers the real key rather than the placeholder text; split placeholders restore across text pushes, SSE events, and byte chunks with per-lane scoping, bounded carry, byte-for-byte opaque/comment/malformed frame preservation, and non-SSE body restoration; placeholder restoration and session behavior remain deterministic. + src/plugins/secrets-redaction/engine.test.tssrc/plugins/secrets-redaction/index.test.tssrc/plugins/secrets-redaction/patterns.test.tssrc/plugins/secrets-redaction/session.test.tssrc/plugins/secrets-redaction/stream.test.tsbun test src/plugins/secrets-redaction/engine.test.ts src/plugins/secrets-redaction/index.test.ts src/plugins/secrets-redaction/patterns.test.ts src/plugins/secrets-redaction/session.test.ts src/plugins/secrets-redaction/stream.test.tsbun run typecheckbun run lintBuilt-in, custom, and configured secrets redact before provider exposure under the family’s resolved settings, restore copy-on-write for frozen native tool input and streamed provider responses — including placeholders split across SSE and WebSocket TEXT deltas with bounded lane carry — and preserve opaque frames.The declared TestFiles and Checks exercise this scenario deterministically.Built-in, regex, custom keyword, and configured web apiKey values redact before provider calls, with ${VAR} placeholders in web apiKey fields resolved from the environment so redaction covers the real key rather than the placeholder text; split placeholders restore across text pushes, SSE events, and byte chunks with per-lane scoping, bounded carry, byte-for-byte opaque/comment/malformed frame preservation, and non-SSE body restoration; placeholder restoration and session behavior remain deterministic. src/lib/spec-lint.test.tssrc/lib/spec-lint-cache.test.tssrc/lib/config-layers.test.tsbun test src/lib/spec-lint.test.ts src/lib/spec-lint-cache.test.ts src/lib/config-layers.test.tsbun run typecheckThe strict engine validates well-formedness, template contracts, identity patterns, references, lifecycle-aware severity, cross-file subset rules, layout checks, the optional delegated execution and review-checkpoint vocabulary with scope paths, reviewer sets, wave barriers, and final coverage, typed extractDelegatedPlanDefinition, and the content-addressed cache with content and version invalidation including rejection of old-version disk caches.The declared TestFiles and Checks exercise this scenario deterministically.Fixture corpus proves every violation class carries a specific rule with file and line, draft documents tolerate emptiness while approved documents do not, plan components must exist in the linked spec, legacy plans without execution stay valid, delegated obligations are required only for approved plans, the shipped reference templates lint clean, and cache runs hit on unchanged inputs and invalidate when any input hash or the lint version changes. src/plugins/spec-guard/index.test.tssrc/lib/plugin-toggle-config.test.tssrc/lib/vvoc-config.test.tssrc/lib/opencode.test.tsbun test src/plugins/spec-guard/index.test.ts src/lib/plugin-toggle-config.test.ts src/lib/vvoc-config.test.ts src/lib/opencode.test.tsbun run typecheckThe spec-guard plugin annotates reads of active artifacts, validates post-write state, refuses only enforce-mode ERROR writes, stays silent on archived files, and fails open on internal errors; strict schema v3 accepts the warn/enforce entry and sync materializes it conservatively.The declared TestFiles and Checks exercise this scenario deterministically.Hook-level tests with injected dependencies prove read verdict injection, archive silence, warn append, enforce refusal and edit prefixes, warning-only and draft states never failing, unreadable-file degradation, schema acceptance and rejection for the spec-guard arm, and canonical sync writes materializing the default warn entry without overwriting user values. - src/tui/context/analyze.test.tssrc/tui/context/collect.test.tssrc/tui/context/plugin.test.tssrc/tui/context/view.test.tssrc/tui/policy.test.tssrc/tui/analytics/indicator.test.tssrc/tui/branding/footer.test.tssrc/tui/rebind/plugin.test.tsbun test src/tui/context/analyze.test.ts src/tui/context/collect.test.ts src/tui/context/plugin.test.ts src/tui/context/view.test.ts src/tui/policy.test.ts src/tui/analytics/indicator.test.ts src/tui/branding/footer.test.ts src/tui/rebind/plugin.test.tsbun run typecheckbun run checkbun run buildbun run pack:checkThe enabled /context command collects the selected session’s location-scoped state through the read-only context-inspection RPC plus native message history and model metadata, computes honest attribution, and renders bounded responsive tabs with fail-soft behavior; the selection-keyed policy controller refreshes on native policy-binding events; the default TUI module additionally registers the analytics indicator, branding footer, app_bottom peak-hours banner, and /vv-rebind command with per-registration fail-soft isolation; /vv-rebind writes exactly one project-scoped rebind request marker naming the active session, is disabled without an open session, and answers with one bounded toast.The declared TestFiles and Checks exercise this scenario deterministically.Percentages derive only from a positive finite context limit; absent or malformed usage stays unknown field-by-field instead of zero; the latest completed compaction cutoff separates pre/post occupancy and compaction request tokens never count as occupancy; registered-catalog budgets stay separate from observed context and provider residual; native built-in and vvoc tools classify while unknown tools stay unattributed under Other external/plugin instead of being guessed onto an MCP server; unavailable or partial catalogs and failed/mismatched RPC reads are disclosed as unknown rather than empty; skill history is counted once; drift is reported positively; host-owned tab composition, middle-half host geometry, preloaded reactive keyboard navigation and cleanup, scrolling, narrow rendering, post-replacement xlarge sizing, and indicator/footer/banner registration tolerance remain deterministic; /vv-rebind writes one marker carrying the TUI working directory and the active session id, shows a single bounded toast, never displays configuration content or credential values, and writes nothing without an open session. - src/plugins/web-tools.integration.test.tssrc/plugins/web-tools/contracts.test.tsbun test src/plugins/web-tools.integration.test.ts src/plugins/web-tools/contracts.test.tsbun run typecheckThe plugin always registers exactly the two owned tools, publishes closed provider-neutral query/count/freshness and URL/format/timeout schemas that reject unknown or invalid provided values before dispatch, and selects owned versus genuine native builtin visibility per bound family from the captured policy without mutating permissions; it reports provider metadata without credentials.The declared TestFiles and Checks exercise this scenario deterministically.An enabled capture publishes the owned contracts and hides native builtins, an explicitly disabled capture exposes the genuine native builtins and hides the owned tools, an unknown policy exposes neither and refuses the owned provider-bound path, and startup-enabled config never hijacks a disabled captured family; invalid arguments are rejected before permission or dispatch, count 8/markdown/bounded-timeout defaults apply at execute time even when the host forwards unparsed arguments, document/binary content and per-call provider selection stay out of the caller contract, direct Z.AI regions and credential sources are reported safely, git-tracked literal apiKey warnings name no values, and logs contain no credential values. - src/plugins/tool-history-compaction/config.test.tssrc/plugins/tool-history-compaction/prune.test.tssrc/plugins/tool-history-compaction/read-slim.test.tssrc/plugins/tool-history-compaction/transform.test.tssrc/plugins/tool-history-compaction/saved-output.test.tssrc/plugins/tool-history-compaction.integration.test.tssrc/lib/plugin-toggle-config.test.tssrc/lib/opencode.test.tsbun test src/plugins/tool-history-compaction.integration.test.ts src/plugins/tool-history-compaction/config.test.ts src/plugins/tool-history-compaction/prune.test.ts src/plugins/tool-history-compaction/read-slim.test.ts src/plugins/tool-history-compaction/transform.test.ts src/plugins/tool-history-compaction/saved-output.test.ts src/lib/plugin-toggle-config.test.ts src/lib/opencode.test.tsbun run typecheckThe native session-context-hook transform-only plugin compacts old ephemeral tool outputs (read headers, head/marker/tail pruning) while retaining knowledge outputs under the bound family’s captured policy, never touching inputs or part structure, protecting an absolute recency-time recent-message window plus per-call budget that retained tools do not consume, persisting full pruned outputs for recoverable markers, enforcing min-savings, and materializing strict vvoc config on sync.The declared TestFiles and Checks exercise this scenario deterministically.Deterministic idempotent rewrites over native message and tool-result parts; retained webfetch/search/skill outputs untouched and not consuming protection budget; the newest messages (recency-time window) never rewritten even under size/parallel/reordering pressure; old reads collapse to [Read <file>, lines X-Y] with fail-closed fallback; saved-output markers embed the persisted full-output path and the file exists with original content; error/compacted parts untouched; inputs byte-identical; sync materializes the default config conservatively and strict schema v3 accepts the new keys. - src/plugins/analytics/index.test.tssrc/lib/analytics/store.test.tssrc/lib/analytics/metrics.test.tssrc/commands/analytics.test.tsbun test src/plugins/analytics src/lib/analytics src/commands/analytics.test.tsbun run typecheckNative step and compaction usage events append attributed usage and session records to the local store without fabricated zero tokens or double counting, gated per bound family by captured policy, and the cache-hit-rate CLI aggregates deterministically by date/week/month/session/model/provider/project/vvoc/OpenCode with coverage and filtering.The declared TestFiles and Checks exercise this scenario deterministically.Disabled or unknown captured families record nothing while enabled families keep primary and child attribution distinct; missing or malformed reported usage never becomes a zero-token record; internal and aggregate host usage events are never double counted; token-weighted hit rate returns null instead of 0% when no step is cache eligible; and group-by/order/limit/since/JSON flag validation rejects invalid input by naming the flag. + src/tui/context/analyze.test.tssrc/tui/context/collect.test.tssrc/tui/context/plugin.test.tssrc/tui/context/view.test.tssrc/tui/policy.test.tssrc/tui/analytics/indicator.test.tssrc/tui/branding/footer.test.tssrc/tui/settings/plugin.test.tsbun test src/tui/context/analyze.test.ts src/tui/context/collect.test.ts src/tui/context/plugin.test.ts src/tui/context/view.test.ts src/tui/policy.test.ts src/tui/analytics/indicator.test.ts src/tui/branding/footer.test.ts src/tui/settings/plugin.test.tsbun run typecheckbun run checkbun run buildbun run pack:checkThe enabled /context command collects the selected session’s location-scoped state through the read-only context-inspection RPC plus native message history and model metadata, computes honest attribution, and renders bounded responsive tabs with fail-soft behavior; the selection-keyed policy controller refreshes on native step and model-selection events; the default TUI module additionally registers the analytics indicator, branding footer, app_bottom peak-hours banner, and /vv-settings command with per-registration fail-soft isolation; /vv-settings shows the current family settings with no argument and applies exactly one explicit settings change (preset, profile, role, plugin toggle, pull-defaults) to the active session’s family root through the host, is disabled without an open session, and answers with one bounded toast.The declared TestFiles and Checks exercise this scenario deterministically.Percentages derive only from a positive finite context limit; absent or malformed usage stays unknown field-by-field instead of zero; the latest completed compaction cutoff separates pre/post occupancy and compaction request tokens never count as occupancy; registered-catalog budgets stay separate from observed context and provider residual; native built-in and vvoc tools classify while unknown tools stay unattributed under Other external/plugin instead of being guessed onto an MCP server; unavailable or partial catalogs and failed/mismatched RPC reads are disclosed as unknown rather than empty; skill history is counted once; drift is reported positively; host-owned tab composition, middle-half host geometry, preloaded reactive keyboard navigation and cleanup, scrolling, narrow rendering, post-replacement xlarge sizing, and indicator/footer/banner registration tolerance remain deterministic; /vv-settings resolves the action through the shared parser, shows bounded summaries that never include credential values, contains apply or read failures as one error toast, and touches nothing without an open session. + src/plugins/web-tools.integration.test.tssrc/plugins/web-tools/contracts.test.tsbun test src/plugins/web-tools.integration.test.ts src/plugins/web-tools/contracts.test.tsbun run typecheckThe plugin always registers exactly the two owned tools, publishes closed provider-neutral query/count/freshness and URL/format/timeout schemas that reject unknown or invalid provided values before dispatch, and selects owned versus genuine native builtin visibility per family from the settings projection without mutating permissions; it reports provider metadata without credentials.The declared TestFiles and Checks exercise this scenario deterministically.An enabled settings projection publishes the owned contracts and hides native builtins, an explicitly disabled projection exposes the genuine native builtins and hides the owned tools, an unknown policy exposes neither and refuses the owned provider-bound path, and startup-enabled config never hijacks a family whose record disabled the plugin; invalid arguments are rejected before permission or dispatch, count 8/markdown/bounded-timeout defaults apply at execute time even when the host forwards unparsed arguments, document/binary content and per-call provider selection stay out of the caller contract, direct Z.AI regions and credential sources are reported safely, git-tracked literal apiKey warnings name no values, and logs contain no credential values. + src/plugins/tool-history-compaction/config.test.tssrc/plugins/tool-history-compaction/prune.test.tssrc/plugins/tool-history-compaction/read-slim.test.tssrc/plugins/tool-history-compaction/transform.test.tssrc/plugins/tool-history-compaction/saved-output.test.tssrc/plugins/tool-history-compaction.integration.test.tssrc/lib/plugin-toggle-config.test.tssrc/lib/opencode.test.tsbun test src/plugins/tool-history-compaction.integration.test.ts src/plugins/tool-history-compaction/config.test.ts src/plugins/tool-history-compaction/prune.test.ts src/plugins/tool-history-compaction/read-slim.test.ts src/plugins/tool-history-compaction/transform.test.ts src/plugins/tool-history-compaction/saved-output.test.ts src/lib/plugin-toggle-config.test.ts src/lib/opencode.test.tsbun run typecheckThe native session-context-hook transform-only plugin compacts old ephemeral tool outputs (read headers, head/marker/tail pruning) while retaining knowledge outputs under the family’s resolved settings, never touching inputs or part structure, protecting an absolute recency-time recent-message window plus per-call budget that retained tools do not consume, persisting full pruned outputs for recoverable markers, enforcing min-savings, and materializing strict vvoc config on sync.The declared TestFiles and Checks exercise this scenario deterministically.Deterministic idempotent rewrites over native message and tool-result parts; retained webfetch/search/skill outputs untouched and not consuming protection budget; the newest messages (recency-time window) never rewritten even under size/parallel/reordering pressure; old reads collapse to [Read <file>, lines X-Y] with fail-closed fallback; saved-output markers embed the persisted full-output path and the file exists with original content; error/compacted parts untouched; inputs byte-identical; sync materializes the default config conservatively and strict schema v3 accepts the new keys. + src/plugins/analytics/index.test.tssrc/lib/analytics/store.test.tssrc/lib/analytics/metrics.test.tssrc/commands/analytics.test.tsbun test src/plugins/analytics src/lib/analytics src/commands/analytics.test.tsbun run typecheckNative step and compaction usage events append attributed usage and session records to the local store without fabricated zero tokens or double counting, gated per family by its resolved settings, and the cache-hit-rate CLI aggregates deterministically by date/week/month/session/model/provider/project/vvoc/OpenCode with coverage and filtering.The declared TestFiles and Checks exercise this scenario deterministically.Families whose settings disable the plugin record nothing while enabled families keep primary and child attribution distinct; missing or malformed reported usage never becomes a zero-token record; internal and aggregate host usage events are never double counted; token-weighted hit rate returns null instead of 0% when no step is cache eligible; and group-by/order/limit/since/JSON flag validation rejects invalid input by naming the flag. src/lib/peak-hours.test.tsbun test src/lib/peak-hours.test.tsbun run typecheckWindow parsing and evaluation with an injectable clock covers same-day, cross-midnight, timezone, and weekday cases with boundary dates and fail-open malformed input.The declared TestFiles and Checks exercise this scenario deterministically.Table-driven deterministic instants prove start-inclusive end-exclusive boundaries, cross-midnight head and tail matching with weekday gating on the opening day, timezone evaluation, provider alias resolution, suggestion filtering with dedupe, bounded formatting, and conservative plugin entry parsing with warnings for malformed pieces. src/plugins/peak-hours/index.test.tssrc/lib/plugin-toggle-config.test.tssrc/lib/vvoc-config.test.tsbun test src/plugins/peak-hours/index.test.ts src/lib/plugin-toggle-config.test.ts src/lib/vvoc-config.test.tsbun run typecheckThe native session model.request hook applies hard blocks by provider peak windows with grace, exemptions, dynamic suggestions, and fail-open degradation; the default soft mode is pass-through with no prompt mutation; and a loopback real-host smoke proves hard-block zero-dispatch beside a soft negative control with native usage telemetry.The declared TestFiles and Checks exercise this scenario deterministically.Injected clock, session, and provider doubles prove the model.request hard throw raised before dispatch, naming provider, window end, wait, and off-peak suggestions, all-peak degradation, grace from persisted session fields, pre-window and parentID session exemptions, internal-kind/internal-agent and managed-subagent exemptions, per-provider mode overrides, fail-open session lookups, soft pass-through without prompt mutation, and disabled-entry or unknown-family-policy no-block behavior; schema tests prove strict entry validation with unchanged existing arms. src/tui/peak-hours/banner.test.tsbun test src/tui/peak-hours/banner.test.tsbun run typecheckThe app_bottom banner registers toggle-gated and renders only while the current model’s provider is in peak, with suggestions and fail-soft failures.The declared TestFiles and Checks exercise this scenario deterministically.Marker-renderer tests prove banner text with and without suggestions, current-model resolution from session messages with config fallback, show and hide cases, warning coloring, plugin id and order, disabled toggle and entry gating, missing slot API tolerance, and registration failure tolerance without real OpenTUI rendering. @@ -81,11 +81,11 @@ src/plugins/web-tools/html-markdown.test.tsbun test src/plugins/web-tools/html-markdown.test.tsbun run typecheckHTML conversion removes executable content and preserves visible structure in Markdown and text output.The declared TestFiles and Checks exercise this scenario deterministically. src/plugins/web-tools/providers/spider.test.tsbun test src/plugins/web-tools/providers/spider.test.tsbun run typecheckSpider request mapping, textual extraction, direct media routing, metadata, and credential-safe failures remain deterministic.The declared TestFiles and Checks exercise this scenario deterministically. src/plugins/web-tools/media-loader.test.tsbun test src/plugins/web-tools/media-loader.test.tsbun run typecheckSupported image and PDF payloads are identified by MIME or signature and returned as bounded attachments.The declared TestFiles and Checks exercise this scenario deterministically. - src/commands/role.test.tsbun test src/commands/role.test.tsbun run lintbun run fmt:checkRole list, set, and unset preserve built-in protections, normalize assignments, and respect selected scope; set and unset support --force with an optional --session rebind request.The declared TestFiles and Checks exercise this scenario deterministically. + src/commands/role.test.tsbun test src/commands/role.test.tsbun run lintbun run fmt:checkRole list, set, and unset preserve built-in protections, normalize assignments, and respect selected scope; changes target newly created sessions while running families keep their recorded settings until an explicit session-settings change.The declared TestFiles and Checks exercise this scenario deterministically. src/commands/config-validate.test.tsbun test src/commands/config-validate.test.tsbun run lintbun run fmt:checkbun run buildCanonical vvoc validation accepts valid schema v3 and reports syntax, schema, role, preset, and orchestration failures without mutation.The declared TestFiles and Checks exercise this scenario deterministically. - src/commands/preset.test.tsbun test src/commands/preset.test.tsbun run lintbun run fmt:checkPreset list, show, and atomic apply validate all declared roles and orchestration before one conservative write; --force with an optional --session writes a project-scoped rebind request consumed lazily by the running runtime.The declared TestFiles and Checks exercise this scenario deterministically. + src/commands/preset.test.tsbun test src/commands/preset.test.tsbun run lintbun run fmt:checkPreset list, show, and atomic apply validate all declared roles and orchestration before one conservative write; changes target newly born families while running families keep their recorded settings until an explicit session-settings change.The declared TestFiles and Checks exercise this scenario deterministically. src/commands/plugin-list.test.tsbun test src/commands/plugin-list.test.tsbun run lintbun run fmt:checkbun run buildPlugin listing reports configured entries, selected source, toggle state, and empty or malformed states safely.The declared TestFiles and Checks exercise this scenario deterministically. - src/commands/plugin-toggle.test.tsbun test src/commands/plugin-toggle.test.tsbun run lintbun run fmt:checkPlugin enable and disable validate known names, preserve unrelated config, and report restart guidance; both support --force with an optional --session rebind request.The declared TestFiles and Checks exercise this scenario deterministically. + src/commands/plugin-toggle.test.tsbun test src/commands/plugin-toggle.test.tsbun run lintbun run fmt:checkPlugin enable and disable validate known names, preserve unrelated config, and report restart guidance; changes target newly born families while running families keep their recorded settings until an explicit session-settings change.The declared TestFiles and Checks exercise this scenario deterministically. src/commands/patch-provider.test.tsbun test src/commands/patch-provider.test.tsbun run buildProvider patch presets validate scope and merge base URLs or real provider model objects with effort/thinking variants without clobbering unrelated configuration: the StepFun .ai baseURL with step-3.7-flash, codex effort variants with reasoning summary and encrypted reasoning over the real gpt-5.5/5.6/6 models plus the legacy gpt-5.3-codex-spark medium variant, the image-capable deepseek-flash max and high variants with 384000-token output limits, deepseek-v4-flash max, the zai glm-5.3 high/max and glm-5.3-flash max variants, qwen3.8-max xhigh, and the xiaomi mimo-v2.6-flash thinking variant (thinking.type=enabled, no PDF declaration, no reasoningEffort); `openai` aliases `codex`, `all` applies every preset, and no kimi patch or vv-mimo high alias exists.The declared TestFiles and Checks exercise this scenario deterministically. src/commands/completion.test.tsbun test src/commands/completion.test.tsbun run buildBash, zsh, and fish completions expose the current command tree, roles, presets, orchestration profiles, and patch providers.The declared TestFiles and Checks exercise this scenario deterministically. src/commands/init.test.tsbun test src/commands/init.test.tsbun run buildProject initialization registers the pinned combined package once in the native plugins array, scaffolds managed agents and skills with the native skills registration, seeds canonical config with the vv-controller default agent, and reruns idempotently.The declared TestFiles and Checks exercise this scenario deterministically. diff --git a/README.md b/README.md index a1fb9741..7202aedb 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,7 @@ The server plugins run inside the OpenCode server process; **ContextTuiPlugin** | Plugin | What it does | |---|---| | **WorkflowPlugin** | A state machine over multi-agent work: explicit work items, required reviewers, bounded implementation/review rounds, and hard stops when more context is needed. | -| **ModelRolesPlugin** | Semantic model roles (`vv-role:smart`, `vv-role:fast`, …) instead of hardcoded model IDs in agents, subagents, and commands — resolved from the effective vvoc role map plus the native `modelIntent` envelope each time a session family binds its snapshot. | +| **ModelRolesPlugin** | Semantic model roles (`vv-role:smart`, `vv-role:fast`, …) instead of hardcoded model IDs in agents, subagents, and commands — resolved from the family settings recorded in the root session's metadata (with the live configuration as the fallback) each time a session or its children are created. | | **GuardianPlugin** | Keeps long or AFK runs moving by auto-approving routine low-risk permission requests; anything risky stays in OpenCode's normal manual approval flow. The assessment uses the captured `fast` auxiliary model by default, or an opt-in provider-neutral System One decision provider. | | **HashlineEditPlugin** | Routes each model to exactly one native edit tool (host `edit` for GLM/Qwen/Kimi, host `apply_patch` for GPT, `str_replace_editor` for DeepSeek, `hashline_edit` for unmatched models) and hides the other edit tools per session. | | **SystemContextInjectionPlugin** | Injects universal guidance — including correctness obligations and evidence discipline for behavior changes — plus the work policy selected by the orchestration profile into vv-controller at startup; subagents stay unpolluted. Skill discovery is registered by `vvoc install`/`sync` through the native `skills` config, not by a plugin. | @@ -233,6 +233,7 @@ Skills are loaded by OpenCode at session start through the native `skills` strin | `vvoc config validate` | Validate canonical `vvoc.json` | | `vvoc role list\|set\|unset` | Manage model role assignments | | `vvoc preset list\|show\|` | Inspect or apply named presets | +| `vvoc session set` | Change one session family's recorded settings (preset, profile, role, plugin toggle, or pull current defaults) through the running host | | `vvoc guardian config` | Print or write the guardian section | | `vvoc plugin list` | List OpenCode plugin entries | | `vvoc plugin enable\|disable` | Toggle a vvoc-managed plugin on or off | @@ -311,9 +312,9 @@ The native document shape vvoc validates, extends, and writes into: `plugins` is an array of package strings or `{package, options}` objects (plus `-target` removal directives); `agents` holds per-agent overrides while agent prompts live as discovered markdown; `skills` is a string array of discovery paths; `providers` carries patched provider/model objects; `permissions` carries host permission rules. There is no `plugin`/`agent`/`provider`/`command` singular key, no `small_model`, and no `tools` key. -Role intent has a native home as well: the pinned package entry's `options.modelIntent` envelope (`model`, `smallModel`, `agents`, `commands`) carries root, small-model, agent, and command model intent inside the native document — native 2.0.18 has no `small_model` field and rejects role references in `model` and `agents..model`, so vv-opencode never writes a `vv-role:*` reference as a native model literal (the model-overrides APIs route role intent into the envelope instead). Role assignments themselves live in `vvoc.json`: `vvoc role set` and preset application edit only that file, and the model-roles runtime combines the effective role map with the raw envelope intent to resolve concrete `provider/model#variant` selections (for example `xiaomi/mimo-v2.6-flash#thinking`) when each session family binds its snapshot. +Role intent has a native home as well: the pinned package entry's `options.modelIntent` envelope (`model`, `smallModel`, `agents`, `commands`) carries root, small-model, agent, and command model intent inside the native document — native hosts in the supported window have no `small_model` field and reject role references in `model` and `agents..model`, so vv-opencode never writes a `vv-role:*` reference as a native model literal (the model-overrides APIs route role intent into the envelope instead). Role assignments themselves live in `vvoc.json`: `vvoc role set` and preset application edit only that file, and the model-roles runtime combines the effective role map with the raw envelope intent to resolve concrete `provider/model#variant` selections (for example `xiaomi/mimo-v2.6-flash#thinking`) for newly created sessions. -Runtime plugins load the effective `vvoc.json` and each session family binds an immutable, credential-free behaviour-policy snapshot at its first workload. The binding is content-addressed and independent of the package version, so a bound family keeps its models, plugin policy, and orchestration profile across a reload and across a server restart, while a new session always resolves the current configuration. A family changes only when you explicitly ask: pass `--force` to `vvoc preset apply`, `vvoc plugin enable|disable`, or `vvoc role set|unset` (optionally with `--session `) to rebind the active families of the current project to the current configuration, or run `/vv-rebind` in the OpenCode TUI to rebind the current session family only. `/vv-rebind` takes no arguments: it writes the same project-scoped rebind request marker naming the active session, and the re-resolved configuration applies from the family's next message (family-wide: the session plus its forks and subagents), with models switching only when their resolved selection actually changed. The command is not the native host `/reload` — that reloads host configuration, while `/vv-rebind` re-resolves vvoc policy for one session family — and it is normally unnecessary for credentials alone, since provider and web keys are already re-attached from the live configuration on every request. Run it from the project the session belongs to; a session in another worktree needs the command run from that worktree. The previous version's snapshot store is never read; the first start after upgrading rebinds existing families once to the current configuration. Credentials are never persisted — provider and web keys are re-attached from the live configuration at request time. Orchestration policy is injected at the request tail rather than into the system prefix, so switching policy does not invalidate the cached prompt prefix. +Runtime plugins resolve a session family's settings from native session metadata: the first time a session family (a root session plus its forks and subagents) is seen, the resolved roles, orchestration profile, and plugin toggles from the effective `vvoc.json` are recorded under the `vvoc:settings` key of the root session's metadata, and the family keeps resolving that record — across config edits and across server restarts — until it is explicitly changed. A new session always births the current configuration, and provider and web credentials are never persisted: they keep coming from the live configuration at request time. A family changes only when you explicitly ask: run `vvoc session set --session ` with one of `--preset `, `--profile `, `--role =` (or `--role unset:`), `--plugin =on|off`, or `--pull-defaults` (re-record the current defaults), or run `/vv-settings` in the OpenCode TUI with the same one-line actions (`preset `, `profile `, `role `, `role unset `, `plugin on|off`, `pull-defaults`) — with no argument it shows the current family settings. Both change only the family of the session you name; every write is a read-modify-write of the metadata key, so unrelated metadata on the session survives. The first start after upgrading converts the previous version's family bindings into root metadata once and then removes the old stores. Orchestration policy is injected at the request tail rather than into the system prefix, so switching policy does not invalidate the cached prompt prefix. ### Strict schema, loud failures @@ -522,7 +523,7 @@ Passed milestones stay historical: later planned edits are covered by later chec Source-independent execution is built on one common contract. Requirements may come from a native package, a provided plan reference, or the current conversation: a native package registers with `work_checkpoint register` (`planPath`), while a provided-plan or conversation-scoped run registers once with `work_item_open` carrying an `execution` descriptor (`executionKey`, `source`, `goal`, `boundary`) and its first task batch, and later tasks append with the same `runId` plus `amendmentId` and `rationale`. Generic review obligations run through `work_checkpoint` actions `start`, `review`/`bind`, `complete`, and `amend`. The registry validates task and checkpoint identities, exact references, execution boundaries, and acyclic dependencies before anything is published, and a selected source stays authoritative — a failed native package is never silently reopened as weaker generic execution, and a provided document is never converted or executed as commands. Required reviews are exactly those registered by the source, the user, or an explicit controller decision; a task with no assigned review completes honestly as controller-accepted rather than as independently reviewed, and a final checkpoint, when one was assigned, must still cover the current revision before completion. Explicit autonomous authority is recorded through `work_checkpoint action "authorize"` from an eligible root-user instruction as a bounded advance reserve shared by the whole authorized execution, consumed one unit at a time only after ordinary allowances are exhausted; duplicate calls, replay, restart, and task replacement cannot refill it, and narrowing or revocation disables future credits without erasing history. The same recorded authority can authorize one bounded continuation that predates a stop, while the separate post-stop `userMessageId` path keeps its own timing rules. -Delegated and checkpoint state persists under `$XDG_DATA_HOME/vvoc/workflow//workflow-state.json` at snapshot version 4, which adds the common execution registry and session-wide root-message claims on top of version 3's recovery histories, replay-protected authorization references, recovery-aware budgets, and rejected-report attempts. Existing native plan runs materialize a compatibility registry entry with their original run ids and content hashes. Older supported snapshots load conservatively: version 3 and 2 files hydrate with empty execution registries (version 2 also with empty recovery histories and original budgets), and version 1 files hydrate as legacy records without plan runs; none ever synthesizes acceptance, approval, or recovery. A snapshot written by a newer plugin version is refused rather than reset. Recovery mutations commit on the live store and persist synchronously — a failed write rolls the mutation back, so changed launch permissions appear only after a durable record; terminal report settlements stage, persist, and then commit. Because runtime plugins share the startup config snapshot, a plugin upgrade takes effect after an OpenCode restart. +Delegated and checkpoint state persists under `$XDG_DATA_HOME/vvoc/workflow//workflow-state.json` at snapshot version 4, which adds the common execution registry and session-wide root-message claims on top of version 3's recovery histories, replay-protected authorization references, recovery-aware budgets, and rejected-report attempts. Existing native plan runs materialize a compatibility registry entry with their original run ids and content hashes. Older supported snapshots load conservatively: version 3 and 2 files hydrate with empty execution registries (version 2 also with empty recovery histories and original budgets), and version 1 files hydrate as legacy records without plan runs; none ever synthesizes acceptance, approval, or recovery. A snapshot written by a newer plugin version is refused rather than reset. Recovery mutations commit on the live store and persist synchronously — a failed write rolls the mutation back, so changed launch permissions appear only after a durable record; terminal report settlements stage, persist, and then commit. Because runtime plugins resolve family settings from session metadata and project configuration, a plugin upgrade or configuration change takes effect for new sessions after the change is loaded; running families keep their recorded settings until an explicit `vvoc session set` or `/vv-settings` change. Plans without an `` section keep their legacy meaning; executing them with different acceptance or review obligations requires an explicit agreed plan amendment, and archived plans are never rewritten. In a deterministic dispatch-count scenario, a twenty-task plan with two intermediate code-review checkpoints and one final spec+code checkpoint needs exactly four initial reviewer launches instead of forty — that is a property of the declared checkpoint policy, not a wall-clock or model-quality benchmark. diff --git a/scripts/e2e-v2.test.ts b/scripts/e2e-v2.test.ts index ccfa640b..f93e281b 100644 --- a/scripts/e2e-v2.test.ts +++ b/scripts/e2e-v2.test.ts @@ -99,35 +99,15 @@ function fakeRuntime(calls: string[]): unknown { location: { directory: "/tmp/vvoc-e2e-project" }, identity: { directory: "/tmp/vvoc-e2e-project" }, }, - snapshots: { - async captures() { - calls.push("captures"); - return []; - }, - async variants() { - calls.push("variants"); - return []; - }, + settings: { async familyOf(id: string) { calls.push("familyOf"); return id; }, - async policy() { - calls.push("policy"); + async resolve() { + calls.push("resolve"); return undefined; }, - async configFor() { - calls.push("configFor"); - return undefined; - }, - async hasStaged() { - calls.push("hasStaged"); - return false; - }, - }, - async admitWorkload(request: Record) { - calls.push("admitWorkload"); - return { status: "bound", sessionID: request.sessionID }; }, async client() { calls.push("client"); @@ -494,6 +474,16 @@ describe("fixture plugin forwarding", () => { expect(client.status).toBe(200); expect(calls).toContain("client"); + const settings = await fetch(`http://127.0.0.1:${info.port}/settings?sessionID=ses_x`, { + headers: { "x-e2e-nonce": info.nonce }, + }); + expect(settings.status).toBe(200); + const settingsBody = (await settings.json()) as { familyId: string; settings: unknown }; + expect(settingsBody.familyId).toBe("ses_x"); + expect(settingsBody.settings).toBeNull(); + expect(calls).toContain("familyOf"); + expect(calls).toContain("resolve"); + const guard = hookHandlers.get("model.request"); expect(guard).toBeDefined(); const invokeGuard = async (event: unknown): Promise => { diff --git a/scripts/e2e-v2/cases.ts b/scripts/e2e-v2/cases.ts index 26c50b19..992e6049 100644 --- a/scripts/e2e-v2/cases.ts +++ b/scripts/e2e-v2/cases.ts @@ -1,9 +1,9 @@ #!/usr/bin/env bun // FILE: scripts/e2e-v2/cases.ts -// VERSION: 2.0.0 +// VERSION: 3.0.0 // START_MODULE_CONTRACT -// PURPOSE: Define the core real-host acceptance cases and their observed, per-request-attributed assertions for the packed native model-role runtime. -// SCOPE: Case descriptors, the driver contract, a bounded assertion collector, and executable cases for activation/guards, awaited admission with zero-dispatch rejection, captured source variants with native registry readback, auxiliary title with zero native title dispatch, config mutation, owned generate/synthetic and raw-unbound refusal, explicit selection, implicit (no agent/model) first-turn admission, fork/worktree lineage, resource permission guard effects, and a hook-availability probe. Assertions are attributed to the exact current session/request via the provider payload markers and hosted session id. +// PURPOSE: Define the core real-host acceptance cases and their observed, per-request-attributed assertions for the packed session-owned family-settings runtime. +// SCOPE: Case descriptors, the driver contract, a bounded assertion collector, and executable cases for activation and lazy settings birth, zero-dispatch rejection, native role variants with registry readback, auxiliary title with zero native title dispatch, config mutation with stable family settings, native generate/synthetic without any admission gateway, explicit selection preservation, fork/worktree lineage, resource permission guard effects, settings stability under an explicit switch race, and a hook-availability probe. Assertions are attributed to the exact current session/request via the provider payload markers and hosted session id. // DEPENDS: [scripts/e2e-v2/host.ts, scripts/e2e-v2/provider.ts, scripts/e2e-v2/fixtures/plugin.ts] // LINKS: [M-E2E-V2-HARNESS, V-M-E2E-V2-HARNESS] // ROLE: SCRIPT @@ -26,15 +26,16 @@ // attributedSessionID - Hosted session id embedded in a provider request environment block. // containsMarker - True when a provider request carries an exact case marker. // payloadFields - Extract the smoke variant and reasoning effort fields from a provider body. -// qualifiedSuffix - True when a variant string ends with the expected capture suffix. +// qualifiedSuffix - True when a variant string ends with the expected suffix. +// isTitleGeneration - True when a provider request is the auxiliary child's title generation. // containsValue - Recursively search a parsed value for an exact string property value. // waitForPendingPermission - Poll the native session permission list for the first pending request id. // replyPermission - Submit a permission decision for a pending native request. // END_MODULE_MAP // // START_CHANGE_SUMMARY -// LAST_CHANGE: [Direct fix - Accepted prerelease semver in the activation packed-version assertion so the core suite stays green across the rc release cycle.] -// PREVIOUS: [C-OPENCODE-V2-NATIVE T-003 correction - Per-request attribution, zero-dispatch rejection, guard effects, owned generate/synthetic, explicit selection, fork/worktree, and native registry readback.] +// LAST_CHANGE: [C-SESSION-OWNED-SETTINGS T-006 - Cases now assert session-owned family settings: lazy metadata birth, config-change stability, native role variants, and no admission gateway; qualified snapshot variants are gone.] +// PREVIOUS: [Direct fix - Accepted prerelease semver in the activation packed-version assertion so the core suite stays green across the rc release cycle.] // END_CHANGE_SUMMARY import type { ProviderRequestRecord } from "./provider.js"; @@ -174,12 +175,20 @@ function bodyText(record: ProviderRequestRecord): string { } } -/** Hosted session id embedded in a provider request's environment block. */ +/** Hosted session id found in a provider request body, when the host embeds one. */ function attributedSessionID(record: ProviderRequestRecord): string | undefined { - const match = /Current conversation session ID: (ses_[A-Za-z0-9]+)/.exec(bodyText(record)); + const text = bodyText(record); + const match = + /Current conversation session ID: (ses_[A-Za-z0-9]+)/.exec(text) ?? + /sessionID["']?\s*[:=]\s*["']?(ses_[A-Za-z0-9]+)/.exec(text); return match?.[1]; } +/** True when a provider request is the auxiliary child's title generation. */ +function isTitleGeneration(record: ProviderRequestRecord): boolean { + return bodyText(record).includes("Generate a short, specific title"); +} + /** True when a provider request carries an exact case marker. */ function containsMarker(record: ProviderRequestRecord, marker: string): boolean { return bodyText(record).includes(marker); @@ -275,31 +284,47 @@ export const coreCases: readonly CoreCase[] = [ client.status === 200 && client.body?.ok === true, "authenticated same-instance native client challenge succeeded", ); - const captures = await driver.control("/captures"); - check.truthy(Array.isArray(captures.body.data), "durable capture registry is readable"); const fresh = await driver.createSession(); await delay(800); - const freshPolicy = await driver.control(`/policy?sessionID=${fresh}`); - check.truthy(freshPolicy.body.capture === null, "a fresh session has no bound policy"); + const freshSettings = await driver.control(`/settings?sessionID=${fresh}`); + check.equal( + freshSettings.body?.familyId, + fresh, + "a location-only session is its own family root", + ); + check.equal( + freshSettings.body?.settings?.version, + 1, + "the fresh family root carries a born version-1 settings record", + ); + check.equal( + freshSettings.body?.source, + "metadata", + "the born settings record is served from session metadata", + ); const reloads = status.body.eventCounts?.["model.updated"] ?? 0; check.truthy(reloads < 60, `model.updated reload count stays bounded (${reloads})`); return { - detail: "packed package imported, mandatory guards registered, fresh sessions unbound", + detail: + "packed package imported, mandatory guards registered; a fresh family births metadata settings", observed: { instanceId: status.body.instanceId, guardState: status.body.guardState, eventCounts: status.body.eventCounts, + settingsId: freshSettings.body?.settingsId, }, }; }, }, { id: "admission", - title: "Awaited admission, rejected preparation, and zero-dispatch rejection", + title: "Rejected preparation dispatches nothing; the valid prompt resolves family settings", phase: "T-003", - parity: ["plugin.model-roles.admission", "plugin.model-roles.rollback"], + parity: ["plugin.model-roles.birth", "plugin.model-roles.fail-soft"], async run(driver, check) { - const sessionID = await driver.createSession(); + // vv-controller carries the override model in the fixture config, so the + // native role variant and payload assertions stay meaningful. + const sessionID = await driver.createSession({ agent: "vv-controller" }); await delay(1500); const prevIdle = (await driver.sessionInfo(sessionID))?.time?.idle; const rejectMarker = nextMarker("reject"); @@ -317,52 +342,56 @@ export const coreCases: readonly CoreCase[] = [ afterReject.every((record) => !containsMarker(record, rejectMarker)), "rejected preparation produced zero provider dispatch for that input", ); - const afterRejectPolicy = await driver.control(`/policy?sessionID=${sessionID}`); - check.truthy( - afterRejectPolicy.body.capture !== null, - "a preparation rejection after the prompt hook still binds the family policy on first work", - ); const acceptMarker = nextMarker("accept"); const acceptBase = await driver.providerCount(); const accepted = await driver.prompt(sessionID, acceptMarker); check.truthy(accepted.status < 400, `valid prompt accepted (status ${accepted.status})`); - const info = await driver.waitIdleChange(sessionID, prevIdle); + await driver.waitIdleChange(sessionID, prevIdle); const fresh = await driver.providerRequestsSince(acceptBase); - const attributed = fresh.filter( - (record) => - attributedSessionID(record) === sessionID && containsMarker(record, acceptMarker), + // The marker can also appear inside the auxiliary child's embedded + // conversation history, so title generations are excluded from the count. + const markerDispatches = fresh.filter( + (record) => containsMarker(record, acceptMarker) && !isTitleGeneration(record), ); check.equal( - attributed.length, + markerDispatches.length, 1, "the accepted prompt dispatched exactly once for its marker", ); check.truthy( - attributed[0] !== undefined && - payloadFields(attributed[0]).smoke_variant === "override" && - payloadFields(attributed[0]).reasoning_effort === "high", - "the accepted prompt dispatched the source variant merged settings (override/high)", + markerDispatches[0] !== undefined && + payloadFields(markerDispatches[0]).smoke_variant === "override" && + payloadFields(markerDispatches[0]).reasoning_effort === "high", + "the accepted prompt dispatched the native role variant (override/high)", ); - const bound = await driver.control(`/policy?sessionID=${sessionID}`); - check.truthy(bound.body.capture !== null, "the valid prompt bound a durable family policy"); - check.truthy( - qualifiedSuffix(info?.model?.variant, ".seam-smart.override"), - `session model carries the qualified override variant (${info?.model?.variant})`, + const settings = await driver.control(`/settings?sessionID=${sessionID}`); + check.equal(settings.body?.settings?.version, 1, "family settings resolved after the turn"); + check.equal( + settings.body?.source, + "metadata", + "the family resolves its settings from root metadata", + ); + const again = await driver.control(`/settings?sessionID=${sessionID}`); + check.equal( + again.body?.settingsId, + settings.body?.settingsId, + "repeated resolution is stable (no rewrite per request)", ); return { detail: - "rejected preparation dispatched nothing; the valid prompt dispatched once and bound", - observed: { sessionID, rejectedStatus: rejected.status, variant: info?.model?.variant }, + "rejected preparation dispatched nothing; the valid prompt dispatched once and the family settings resolve stably", + observed: { sessionID, rejectedStatus: rejected.status, settingsId: settings.body?.settingsId }, }; }, }, { id: "payload-variants", - title: "Same model, two captured source variants, native registry readback", + title: "Native role variants on one model: controller override payload, child plain payload", phase: "T-003", parity: ["plugin.model-roles.variants", "runtime.model-registry"], async run(driver, check) { - const sessionID = await driver.createSession(); + // vv-controller carries the override model in the fixture config. + const sessionID = await driver.createSession({ agent: "vv-controller" }); await delay(1200); const prevIdle = (await driver.sessionInfo(sessionID))?.time?.idle; const marker = nextMarker("variants"); @@ -370,30 +399,25 @@ export const coreCases: readonly CoreCase[] = [ await driver.prompt(sessionID, marker); const info = await driver.waitIdleChange(sessionID, prevIdle); const variant = info?.model?.variant as string | undefined; - const prefix = - typeof variant === "string" ? variant.replace(/\.seam-smart\..*$/, "") : undefined; - check.truthy( - qualifiedSuffix(variant, ".seam-smart.override"), - `root role uses the override variant (${variant})`, + // The root resolves its explicit agent model natively; vvoc never + // rewrites the session model afterwards. + check.equal( + variant, + "override", + `controller root resolves its override model natively (${variant})`, ); - // Native final registry readback: the qualified variant must be present in the host model list. + // Native final registry readback: the configured variant is present in the host model list. const models = await driver.modelList(); check.truthy( - typeof prefix === "string" && - prefix.length > 0 && - containsValue(models, `${prefix}.seam-smart.override`), - "native model.list contains the family-qualified override variant", + containsValue(models, "override"), + "native model.list contains the configured override variant", ); check.truthy( !containsValue(models, "fabricated.seam-smart.override"), "native model.list does not contain a fabricated variant (negative control)", ); - // Untouched captured descriptors remain readable separately from the registry. - const descriptors = await driver.control(`/variants?sessionID=${sessionID}`); - check.truthy( - Array.isArray(descriptors.body.data) && descriptors.body.data.length >= 2, - "capture service still exposes the source variant descriptors", - ); + // The auxiliary title child dispatches the fast role's distinct payload. + await delay(1500); // allow auxiliary title work to settle const fresh = await driver.providerRequestsSince(base); check.truthy( fresh.some( @@ -410,17 +434,17 @@ export const coreCases: readonly CoreCase[] = [ payloadFields(record).smoke_variant === "plain" && payloadFields(record).reasoning_effort === "minimal", ), - "the same model dispatched a distinct plain/minimal payload", + "the same model dispatched a distinct plain/minimal payload (role fast)", ); return { - detail: "two distinct qualified payloads for one model; final registry readback present", + detail: "two distinct role payloads for one model; final registry readback present", observed: { variant, registryContains: true }, }; }, }, { id: "lineage-title", - title: "Auxiliary title lineage and zero native title provider dispatch", + title: "Auxiliary title lineage, role model, and zero native title provider dispatch", phase: "T-003", parity: ["runtime.lineage", "plugin.model-roles.auxiliary"], async run(driver, check) { @@ -430,11 +454,7 @@ export const coreCases: readonly CoreCase[] = [ const marker = nextMarker("title"); const base = await driver.providerCount(); await driver.prompt(sessionID, marker); - const info = await driver.waitIdleChange(sessionID, prevIdle); - const parentPrefix = - typeof info?.model?.variant === "string" - ? info.model.variant.replace(/\.seam-smart\..*$/, "") - : undefined; + await driver.waitIdleChange(sessionID, prevIdle); await delay(1500); // allow auxiliary title work to settle const children = (await driver.listSessions()).filter( (entry) => entry.parentID === sessionID, @@ -450,68 +470,57 @@ export const coreCases: readonly CoreCase[] = [ JSON.stringify({ vvocAuxiliary: { kind: "title", role: "fast" } }), "child carries host-owned auxiliary kind/role metadata", ); - check.truthy( - typeof child?.model?.variant === "string" && - typeof parentPrefix === "string" && - child.model.variant.startsWith(parentPrefix), - `child shares the family capture prefix (${parentPrefix})`, + check.equal( + child?.model?.variant, + "plain", + `child resolves the family fast role model (${child?.model?.variant})`, + ); + const childSettings = await driver.control(`/settings?sessionID=${children[0].id}`); + check.equal( + childSettings.body?.familyId, + sessionID, + "the child resolves through lineage to the root family", ); const fresh = await driver.providerRequestsSince(base); - const parentDispatches = fresh.filter((record) => attributedSessionID(record) === sessionID); + const parentDispatches = fresh.filter( + (record) => containsMarker(record, marker) && !isTitleGeneration(record), + ); check.equal( parentDispatches.length, 1, - "exactly one provider dispatch is attributed to the parent (no native title dispatch)", - ); - const childDispatches = fresh.filter( - (record) => attributedSessionID(record) === children[0].id, + "exactly one non-title provider dispatch carries the parent marker (no native title dispatch)", ); check.truthy( - childDispatches.some((record) => - bodyText(record).includes("Generate a short, specific title"), - ), + fresh.some((record) => isTitleGeneration(record)), "the auxiliary child performed the title generation instead", ); return { detail: - "auxiliary title is parented, family-qualified, and replaces the native title dispatch", + "auxiliary title is parented, resolves the family fast role, and replaces the native title dispatch", observed: { sessionID, childID: children[0].id, parentDispatches: parentDispatches.length, - childDispatches: childDispatches.length, }, }; }, }, { id: "owned-generate-synthetic", - title: "Owned pre-admission for generate/synthetic before a user prompt, raw-unbound refusal", + title: "Native generate/synthetic complete without any admission gateway; settings resolve family-wide", phase: "T-003", - parity: ["plugin.model-roles.synthetic", "runtime.admission.owned"], + parity: ["plugin.model-roles.synthetic", "runtime.settings.resolve"], async run(driver, check) { - // Owned generate: pre-admit, then run the real native session.generate. + // Native session.generate needs no pre-binding: there is no admission + // gateway, and the family settings resolve fail-soft on first touch. const genSession = await driver.createSession(); await delay(1000); - const genAdmit = await driver.control("/admit", { - method: "POST", - body: { - sessionID: genSession, - explicit: { providerID: "loopback", modelID: "seam-smart" }, - workload: "generate", - force: true, - }, - }); - check.truthy( - genAdmit.body?.outcome?.status === "bound" || genAdmit.body?.outcome?.status === "reused", - `owned generate pre-admission published (${genAdmit.body?.outcome?.status})`, - ); const genMarker = nextMarker("generate"); const genBase = await driver.providerCount(); const generated = await driver.generate(genSession, genMarker); check.truthy( generated.status < 400, - `native session.generate succeeded (${generated.status})`, + `native session.generate succeeded without pre-admission (${generated.status})`, ); const genFresh = await driver.providerRequestsSince(genBase); const genDispatched = genFresh.filter((record) => containsMarker(record, genMarker)); @@ -523,26 +532,18 @@ export const coreCases: readonly CoreCase[] = [ check.truthy( genDispatched[0] !== undefined && payloadFields(genDispatched[0]).smoke_variant === "override", - "owned generate dispatched the family captured variant payload", + "generate dispatched the session's native role variant payload", ); - const genPolicy = await driver.control(`/policy?sessionID=${genSession}`); - check.truthy( - genPolicy.body.capture !== null, - "owned generate bound the family before any ordinary user prompt", + const genSettings = await driver.control(`/settings?sessionID=${genSession}`); + check.equal( + genSettings.body?.settings?.version, + 1, + "the generate family resolves settings without any admission step", ); - // Owned synthetic: pre-admit, then run the real native session.synthetic. + // Native session.synthetic completes the same way. const synSession = await driver.createSession(); await delay(1000); - await driver.control("/admit", { - method: "POST", - body: { - sessionID: synSession, - explicit: { providerID: "loopback", modelID: "seam-smart" }, - workload: "synthetic", - force: true, - }, - }); const synMarker = nextMarker("synthetic"); const synPrevIdle = (await driver.sessionInfo(synSession))?.time?.idle; const synBase = await driver.providerCount(); @@ -560,51 +561,20 @@ export const coreCases: readonly CoreCase[] = [ (record) => containsMarker(record, synMarker) && payloadFields(record).smoke_variant === "override", ), - "owned synthetic dispatched the family captured variant payload", - ); - - // Raw unbound generate must fail before any provider dispatch. - const rawGen = await driver.createSession(); - await delay(800); - const rawGenMarker = nextMarker("unbound-generate"); - const rawGenBase = await driver.providerCount(); - const rawGenerated = await driver.generate(rawGen, rawGenMarker); - await delay(1200); - const rawGenFresh = await driver.providerRequestsSince(rawGenBase); - check.truthy( - rawGenerated.status >= 400, - `raw unbound session.generate is refused (${rawGenerated.status})`, - ); - check.truthy( - rawGenFresh.every((record) => !containsMarker(record, rawGenMarker)), - "raw unbound generate produced zero provider dispatch", + "synthetic dispatched the session's native role variant payload", ); - - // Raw unbound synthetic must also fail before provider dispatch. - const rawSyn = await driver.createSession(); - await delay(800); - const rawSynMarker = nextMarker("unbound-synthetic"); - const rawSynBase = await driver.providerCount(); - const rawSynthetic = await driver.synthetic(rawSyn, rawSynMarker); - await delay(2000); - const rawSynFresh = await driver.providerRequestsSince(rawSynBase); - check.truthy( - rawSynFresh.every((record) => !containsMarker(record, rawSynMarker)), - "raw unbound synthetic produced zero provider dispatch", - ); - const rawSynPolicy = await driver.control(`/policy?sessionID=${rawSyn}`); - check.truthy( - rawSynPolicy.body.capture === null, - "raw unbound synthetic did not bind a family (status " + rawSynthetic.status + ")", + const synSettings = await driver.control(`/settings?sessionID=${synSession}`); + check.equal( + synSettings.body?.familyId, + synSession, + "the synthetic session is its own family root with settings", ); return { detail: - "owned generate/synthetic bound before a user prompt; raw unbound work dispatched nothing", + "generate/synthetic complete natively with no admission gateway; family settings resolve for both", observed: { genStatus: generated.status, synStatus: synthetic.status, - rawGenStatus: rawGenerated.status, - rawSynStatus: rawSynthetic.status, }, }; }, @@ -640,7 +610,7 @@ export const coreCases: readonly CoreCase[] = [ "explicit-at-create dispatched the override payload", ); - // Explicit switch before first binding wins over the config default. + // Explicit switch before first work wins over the config default. const switched = await driver.createSession(); await delay(1000); const switchResult = await driver.switchModel(switched, { @@ -656,8 +626,7 @@ export const coreCases: readonly CoreCase[] = [ await driver.prompt(switched, switchMarker); const switchInfo = await driver.waitIdleChange(switched, switchPrevIdle); check.truthy( - qualifiedSuffix(switchInfo?.model?.variant, ".seam-smart.plain") || - switchInfo?.model?.variant === "plain", + qualifiedSuffix(switchInfo?.model?.variant, "plain"), `explicit switch kept the chosen plain selection (${switchInfo?.model?.variant})`, ); const switchFresh = await driver.providerRequestsSince(switchBase); @@ -679,14 +648,14 @@ export const coreCases: readonly CoreCase[] = [ }, { id: "implicit-first-turn", - title: "Sessions without agent or model complete their first turn on the family variant", + title: "Sessions without agent or model complete their first turn on the native role model", phase: "T-003", - parity: ["plugin.model-roles.admission"], + parity: ["plugin.model-roles.birth"], async run(driver, check) { - // The API/SDK shape: location-only create, no agent, no model. The host may - // resolve the first dispatch before the binding switch lands, so this case - // locks the user-visible contract: the first turn succeeds on the family - // variant, in every creation shape. + // The API/SDK shape: location-only create, no agent, no model. Role + // application happens through native agent definitions at creation, so + // this case locks the user-visible contract: the first turn succeeds on + // the role-resolved model, in every creation shape. const implicit = await driver.createSession(); await delay(1200); const implicitPrevIdle = (await driver.sessionInfo(implicit))?.time?.idle; @@ -700,18 +669,12 @@ export const coreCases: readonly CoreCase[] = [ "succeeded", "a location-only session completes its first turn", ); - check.truthy( - qualifiedSuffix(implicitInfo?.model?.variant, ".seam-smart.override"), - `the implicit session carries the family-qualified variant (${implicitInfo?.model?.variant})`, - ); check.truthy( (await driver.providerRequestsSince(implicitBase)).some( (record) => - attributedSessionID(record) === implicit && - containsMarker(record, implicitMarker) && - payloadFields(record).smoke_variant === "override", + containsMarker(record, implicitMarker) && !isTitleGeneration(record), ), - "the implicit first turn dispatched the family-qualified payload", + "the implicit first turn dispatched exactly its own request", ); // Control: an explicit agent at create keeps working unchanged. @@ -727,9 +690,9 @@ export const coreCases: readonly CoreCase[] = [ "succeeded", "an explicit-agent session completes its first turn", ); - check.truthy( - qualifiedSuffix(agentInfo?.model?.variant, ".seam-smart.override") || - agentInfo?.model?.variant === "override", + check.equal( + agentInfo?.model?.variant, + "override", `the explicit-agent session kept its agent model selection (${agentInfo?.model?.variant})`, ); @@ -748,10 +711,10 @@ export const coreCases: readonly CoreCase[] = [ "succeeded", "an explicit-model session completes its first turn", ); - check.truthy( - qualifiedSuffix(modelInfo?.model?.variant, ".seam-smart.override") || - modelInfo?.model?.variant === "override", - `the explicit-model session kept its qualified selection (${modelInfo?.model?.variant})`, + check.equal( + modelInfo?.model?.variant, + "override", + `the explicit-model session kept its selection (${modelInfo?.model?.variant})`, ); return { detail: @@ -766,20 +729,23 @@ export const coreCases: readonly CoreCase[] = [ }, { id: "config-mutation", - title: "Bound family retains policy while changed configuration binds new work", + title: "Session-owned settings: a born family keeps its settings while new work resolves the changed config", phase: "T-003", - parity: ["runtime.snapshot-persist", "plugin.model-roles.policy"], + parity: ["runtime.settings-persist", "plugin.model-roles.policy"], async run(driver, check) { const first = await driver.createSession(); await delay(1200); const firstPrevIdle = (await driver.sessionInfo(first))?.time?.idle; await driver.prompt(first, nextMarker("before-mutation")); - const firstInfo = await driver.waitIdleChange(first, firstPrevIdle); - const firstVariant = firstInfo?.model?.variant; - check.truthy( - qualifiedSuffix(firstVariant, ".seam-smart.override"), - `initial family bound override (${firstVariant})`, + await driver.waitIdleChange(first, firstPrevIdle); + const firstSettings = await driver.control(`/settings?sessionID=${first}`); + const firstSettingsId = firstSettings.body?.settingsId as string | undefined; + check.equal( + firstSettings.body?.settings?.roles?.smart, + "loopback/seam-smart#override", + "the born family settings carry the override smart role", ); + const firstModel = (await driver.sessionInfo(first))?.model; await driver.writeVvocRoles({ allPlain: true }); await delay(1500); @@ -787,30 +753,45 @@ export const coreCases: readonly CoreCase[] = [ await delay(1200); const secondPrevIdle = (await driver.sessionInfo(second))?.time?.idle; await driver.prompt(second, nextMarker("after-mutation")); - const secondInfo = await driver.waitIdleChange(second, secondPrevIdle); - const secondVariant = secondInfo?.model?.variant; - check.truthy( - qualifiedSuffix(secondVariant, ".seam-smart.plain"), - `new work after mutation binds the new plain policy (${secondVariant})`, + await driver.waitIdleChange(second, secondPrevIdle); + const secondSettings = await driver.control(`/settings?sessionID=${second}`); + check.equal( + secondSettings.body?.settings?.roles?.smart, + "loopback/seam-smart#plain", + "the new family births with the changed role settings", ); check.truthy( - secondVariant !== firstVariant, - "the new family capture differs from the earlier one", + secondSettings.body?.settingsId !== firstSettingsId, + "the two families carry distinct settings records", + ); + // The born family session keeps its native model, and its settings are + // untouched by the config change. + check.equal( + JSON.stringify((await driver.sessionInfo(first))?.model ?? null), + JSON.stringify(firstModel ?? null), + "the born family session keeps its original native model", + ); + const firstAfter = await driver.control(`/settings?sessionID=${first}`); + check.equal( + firstAfter.body?.settingsId, + firstSettingsId, + "the born family settings are untouched by the config change", ); check.equal( - (await driver.sessionInfo(first))?.model?.variant, - firstVariant, - "the already-bound family keeps its original captured variant", + firstAfter.body?.settings?.roles?.smart, + "loopback/seam-smart#override", + "the born family keeps resolving its recorded override role", ); return { - detail: "config change binds new work without mutating an already bound family", - observed: { firstVariant, secondVariant }, + detail: + "config change births new families with the new settings without mutating an already-born family", + observed: { firstSettingsId, secondId: secondSettings.body?.settingsId }, }; }, }, { id: "fork-worktree", - title: "Fork (empty parentID) and worktree move keep root family policy", + title: "Fork (empty parentID) and worktree move keep root family settings", phase: "T-003", parity: ["runtime.lineage", "plugin.model-roles.fork"], async run(driver, check) { @@ -821,10 +802,7 @@ export const coreCases: readonly CoreCase[] = [ const rootInfo = await driver.waitIdleChange(root, rootPrevIdle); const rootFamily = (await driver.control(`/family?sessionID=${root}`)).body .familyId as string; - const rootSnapshot = (await driver.control(`/config?sessionID=${root}`)).body?.capture - ?.snapshotId; - const rootVariant = rootInfo?.model?.variant as string | undefined; - const expectedVariantName = rootVariant?.replace(/.*\.seam-smart\./, ""); + const rootSettings = (await driver.control(`/settings?sessionID=${root}`)).body; const forked = await driver.fork(root); check.truthy( @@ -844,7 +822,13 @@ export const coreCases: readonly CoreCase[] = [ check.equal( forkFamily, rootFamily, - "host-verified family lookup resolves the fork to the root family", + "host-verified lineage resolves the fork to the root family", + ); + const forkSettings = (await driver.control(`/settings?sessionID=${forkID}`)).body; + check.equal( + forkSettings?.settingsId, + rootSettings?.settingsId, + "the fork resolves the root family settings record", ); const projectID = rootInfo?.projectID as string | undefined; @@ -867,11 +851,11 @@ export const coreCases: readonly CoreCase[] = [ const movedFamily = (await driver.control(`/family?sessionID=${forkID}`)).body .familyId as string; check.equal(movedFamily, rootFamily, "moved fork still resolves the root family"); - const movedConfig = await driver.control(`/config?sessionID=${forkID}`); + const movedSettings = (await driver.control(`/settings?sessionID=${forkID}`)).body; check.equal( - movedConfig.body?.capture?.snapshotId, - rootSnapshot, - "moved fork keeps the root family capture", + movedSettings?.settingsId, + rootSettings?.settingsId, + "moved fork keeps the root family settings record", ); const movedModel = (await driver.sessionInfo(forkID))?.model; const marker = nextMarker("fork-moved"); @@ -890,10 +874,9 @@ export const coreCases: readonly CoreCase[] = [ marker: containsMarker(record, marker), session: attributedSessionID(record), })); - // Residual (C-SNAPSHOT-ANCHORING-REDESIGN): the host creates no model - // request for a child session prompted after `/session/{id}/move` in this - // harness (verified through the runtime guard hook log), so the post-move - // provider payload is recorded but not asserted. Lineage, capture, and + // Residual: the host creates no model request for a child session + // prompted after `/session/{id}/move` in this harness, so the post-move + // provider payload is recorded but not asserted. Lineage, settings, and // session-model assertions above stay enforced. movedObserved = { movedModel, freshSummary }; } finally { @@ -901,7 +884,7 @@ export const coreCases: readonly CoreCase[] = [ } return { detail: - "fork lineage and worktree move preserve the root family capture; post-move provider dispatch is recorded as a residual, not asserted", + "fork lineage and worktree move preserve the root family settings; post-move provider dispatch is recorded as a residual, not asserted", observed: { rootFamily, forkID, projectID, ...(movedObserved as object) }, }; }, @@ -964,10 +947,10 @@ export const coreCases: readonly CoreCase[] = [ }, }, { - id: "ordering-first-accepted", - title: "First prompt binds the family; a later explicit switch before idle does not rebind it", + id: "settings-stability", + title: "Settings birth happens once; a later explicit switch never rewrites the record", phase: "T-003", - parity: ["runtime.admission.ordering"], + parity: ["runtime.settings.stability"], async run(driver, check) { await driver.writeVvocRoles({ allPlain: false }); await delay(1500); @@ -977,8 +960,9 @@ export const coreCases: readonly CoreCase[] = [ const markerA = nextMarker("first-staged"); const markerB = nextMarker("last-staged"); // A is staged first by the real prompt hook, then its native preparation is - // delayed by the fixture hook (which only sleeps). B is staged second with - // an explicit plain selection and is expected to be accepted first. + // delayed by the fixture hook (which only sleeps). The settings birth is + // driven by the first request's context work; the explicit switch below + // must never rewrite the born record. const pendingA = driver.prompt(session, `${ORDERING_DELAY_MARKER} ${markerA}`); await delay(700); const switched = await driver.switchModel(session, { @@ -988,7 +972,7 @@ export const coreCases: readonly CoreCase[] = [ }); check.truthy( switched.status < 400, - `policy switch during A preparation accepted (${switched.status})`, + `explicit model switch during A preparation accepted (${switched.status})`, ); await delay(300); const resultB = await driver.prompt(session, markerB); @@ -996,15 +980,25 @@ export const coreCases: readonly CoreCase[] = [ const resultA = await pendingA; check.truthy(resultA.status >= 0, `first prompt request completed (${resultA.status})`); const info = await driver.waitIdleChange(session, prevIdle, 45_000); - check.truthy(info !== undefined, `the session settled after the ordering race`); - const bound = await driver.control(`/policy?sessionID=${session}`); - const boundModel = JSON.stringify(bound.body?.capture?.roleModels ?? {}); - check.truthy( - boundModel.includes("override"), - "the first prompt's override policy bound and survived the later explicit switch", + check.truthy(info !== undefined, `the session settled after the race`); + const settings = await driver.control(`/settings?sessionID=${session}`); + check.equal( + settings.body?.settings?.roles?.smart, + "loopback/seam-smart#override", + "the family settings born at first resolution kept the pre-switch override role", + ); + check.equal( + settings.body?.source, + "metadata", + "the family still resolves from its persisted metadata record", + ); + check.equal( + info?.model?.variant, + "plain", + `the user's explicit switch survived untouched (${info?.model?.variant})`, ); return { - detail: "first prompt's policy bound while a later explicit switch did not rebind it", + detail: "settings birth happened once and a later explicit switch rewrote nothing", observed: { session, variant: info?.model?.variant, diff --git a/scripts/e2e-v2/core.ts b/scripts/e2e-v2/core.ts index 03b1bb8f..88a4a97a 100644 --- a/scripts/e2e-v2/core.ts +++ b/scripts/e2e-v2/core.ts @@ -58,9 +58,8 @@ // END_MODULE_MAP // // START_CHANGE_SUMMARY -// LAST_CHANGE: [Direct fix - Default evidence paths now resolve under the harness scratch base (vvoc-e2e-evidence) instead of the workspace GRACE change tree, so a run never leaves evidence files that turn `grace lint` red.] -// PREVIOUS: [C-OPENCODE-V2-NATIVE T-009-FULL - Installs the packed tarball with its declared dependency graph, verifies per-row outcomes, and drives the INSTALLED root aggregate on the real host control plane: system-context injection, analytics usage, peak-hours PRIMARY gating, web-tools permission-before-network, guardian deny/allow file-write gating, hashline-edit routed-tool + stale-anchor rejection, spec-guard scoped enforcement, and tool-history-compaction of the real dispatched request; retries readiness and deterministically replays the auxiliary family after restart.] -// PREVIOUS: [C-OPENCODE-V2-NATIVE T-009-FULL - Installed-surface checks (root aggregate, standalone subpaths, nine-tool census, presets/variants, managed agents/skills, installed CLI lifecycle).] +// LAST_CHANGE: [C-SESSION-OWNED-SETTINGS T-006 - The restart-replay case now proves persisted family-settings metadata survives a real host restart and keeps driving the same resolution, replacing the snapshot-capture replay.] +// PREVIOUS: [Direct fix - Default evidence paths now resolve under the harness scratch base (vvoc-e2e-evidence) instead of the workspace GRACE change tree, so a run never leaves evidence files that turn `grace lint` red.] // END_CHANGE_SUMMARY import { createServer } from "node:net"; @@ -765,9 +764,9 @@ export async function runCore(options: CoreRunOptions): Promise // START_BLOCK_REPLAY_RESTART // Durable replay after a real service restart: the runner owns the host - // lifecycle, so this case rebinds the same persisted root and auxiliary - // sessions after a fresh host instance and checks both family captures - // and payloads survived. + // lifecycle, so this case re-reads the same persisted root and auxiliary + // sessions after a fresh host instance and checks that the family settings + // metadata (and the resolution it drives) survived unchanged. { const check = new Checks(); const startedAt = Date.now(); @@ -794,25 +793,23 @@ export async function runCore(options: CoreRunOptions): Promise method: "POST", body: JSON.stringify({ text: "pre-restart replay" }), }); - const rootInfo = await driver.waitIdleChange(rootID, beforeRoot); - const rootVariant = rootInfo?.model?.variant as string | undefined; - check.truthy( - typeof rootVariant === "string" && rootVariant.includes(".seam-smart."), - `pre-restart root family bound a snapshot variant (${rootVariant})`, - ); - const rootConfigBefore = await driver.control(`/config?sessionID=${rootID}`); + await driver.waitIdleChange(rootID, beforeRoot); + const rootSettingsBefore = await driver.control(`/settings?sessionID=${rootID}`); + const rootVariant = (await driver.sessionInfo(rootID))?.model?.variant as + | string + | undefined; const aux = (await driver.listSessions()).find((entry) => entry.parentID === rootID); check.truthy(aux !== undefined, "pre-restart auxiliary family exists"); const auxID = aux?.id as string | undefined; + let auxSettingsBefore: any; let auxVariant: string | undefined; - let auxConfigBefore: any; if (auxID !== undefined) { + auxSettingsBefore = await driver.control(`/settings?sessionID=${auxID}`); auxVariant = (await driver.sessionInfo(auxID))?.model?.variant; - auxConfigBefore = await driver.control(`/config?sessionID=${auxID}`); check.equal( - auxConfigBefore.body?.capture?.snapshotId, - rootConfigBefore.body?.capture?.snapshotId, - "pre-restart auxiliary shares the root family snapshot", + auxSettingsBefore.body?.settingsId, + rootSettingsBefore.body?.settingsId, + "pre-restart auxiliary resolves the root family settings record", ); } @@ -893,78 +890,54 @@ export async function runCore(options: CoreRunOptions): Promise control: controlAfterRestart, }); - // Root replay: same persisted capture and same qualified variant/payload. - const beforeRoot2 = (await driverAfterRestart.sessionInfo(rootID))?.time?.idle; - const restartProviderBase = await driverAfterRestart.providerCount(); - await api(`/api/session/${rootID}/prompt`, { - method: "POST", - body: JSON.stringify({ text: "post-restart replay" }), - }); - const rootAfter = await driverAfterRestart.waitIdleChange(rootID, beforeRoot2); + // Root replay: same persisted settings record and same native model. + const rootSettingsAfter = await controlAfterRestart(`/settings?sessionID=${rootID}`); check.equal( - rootAfter?.model?.variant, - rootVariant, - "persisted root family capture replayed after a real service restart", + rootSettingsAfter.body?.settingsId, + rootSettingsBefore.body?.settingsId, + "persisted root family settings replayed after a real service restart", ); - const rootVariantName = - typeof rootVariant === "string" ? rootVariant.replace(/.*\.seam-smart\./, "") : undefined; - const restartFresh = await driverAfterRestart.providerRequestsSince(restartProviderBase); - check.truthy( - restartFresh.some( - (record) => - JSON.stringify(record.body ?? "").includes("post-restart replay") && - (record.body as { smoke_variant?: unknown } | undefined)?.smoke_variant === - rootVariantName, - ), - "the replayed root family dispatched its persisted variant payload after restart", + check.equal( + JSON.stringify(rootSettingsAfter.body?.settings ?? null), + JSON.stringify(rootSettingsBefore.body?.settings ?? null), + "the root settings record is byte-identical after restart", ); - // Auxiliary replay: its configFor still resolves the same snapshot. + check.equal( + (await driverAfterRestart.sessionInfo(rootID))?.model?.variant, + rootVariant, + "the root session kept its native model across the restart", + ); + // Auxiliary replay: its resolution still projects the root record. if (auxID !== undefined) { - const auxConfigAfter = await controlAfterRestart(`/config?sessionID=${auxID}`); + const auxSettingsAfter = await controlAfterRestart(`/settings?sessionID=${auxID}`); check.equal( - auxConfigAfter.body?.capture?.snapshotId, - rootConfigBefore.body?.capture?.snapshotId, - "persisted auxiliary family capture replayed after restart", + auxSettingsAfter.body?.settingsId, + rootSettingsBefore.body?.settingsId, + "persisted auxiliary resolution replays the root family settings after restart", ); check.equal( (await driverAfterRestart.sessionInfo(auxID))?.model?.variant, auxVariant, - "auxiliary child kept its qualified variant after restart", - ); - const auxVariantName = - typeof auxVariant === "string" ? auxVariant.replace(/.*\.seam-smart\./, "") : undefined; - // Drive the auxiliary family directly after restart so its persisted - // capture/variant must dispatch deterministically (a title request is - // not guaranteed for an already-titled session). - const auxProviderBase = await driverAfterRestart.providerCount(); - await api(`/api/session/${auxID}/prompt`, { - method: "POST", - body: JSON.stringify({ text: "aux post-restart replay marker" }), - }).catch(() => undefined); - let auxDispatched = false; - const auxDeadline = Date.now() + 20_000; - while (!auxDispatched && Date.now() < auxDeadline) { - const fresh = await driverAfterRestart.providerRequestsSince(auxProviderBase); - auxDispatched = fresh.some( - (record) => - JSON.stringify(record.body ?? "").includes("aux post-restart replay marker") && - (record.body as { smoke_variant?: unknown } | undefined)?.smoke_variant === - auxVariantName, - ); - if (!auxDispatched) await delay(500); - } - check.truthy( - auxDispatched, - "the auxiliary family dispatched its persisted variant payload after restart", + "auxiliary child kept its role-resolved model after restart", ); } - detail = "persisted root and auxiliary family captures survived a real host restart"; + // A fresh family born after the restart resolves the same effective config. + const fresh = await driverAfterRestart.createSession(); + await delay(1200); + const freshSettings = await controlAfterRestart(`/settings?sessionID=${fresh}`); + check.equal( + freshSettings.body?.settings?.roles?.smart, + rootSettingsBefore.body?.settings?.roles?.smart, + "a post-restart family births the same effective role settings", + ); + detail = "persisted root and auxiliary family settings survived a real host restart"; observed = { rootID, rootVariant, - rootAfterVariant: rootAfter?.model?.variant, auxID, auxVariant, + settingsIdBefore: rootSettingsBefore.body?.settingsId, + settingsIdAfter: rootSettingsAfter.body?.settingsId, restarted: true, }; } catch (error) { @@ -974,9 +947,9 @@ export async function runCore(options: CoreRunOptions): Promise if (status === "pass" && check.failed.length > 0) status = "fail"; results.push({ id: "replay-restart", - title: "Durable root and auxiliary family replay after a real service restart", + title: "Durable root and auxiliary settings replay after a real service restart", phase: "T-003", - parity: ["runtime.snapshot-persist", "runtime.lineage"], + parity: ["runtime.settings-persist", "runtime.lineage"], status, detail, assertions: check.passed, diff --git a/scripts/e2e-v2/fixtures/plugin.ts b/scripts/e2e-v2/fixtures/plugin.ts index 17ec24c6..adb6de1c 100644 --- a/scripts/e2e-v2/fixtures/plugin.ts +++ b/scripts/e2e-v2/fixtures/plugin.ts @@ -2,8 +2,8 @@ // FILE: scripts/e2e-v2/fixtures/plugin.ts // VERSION: 2.0.0 // START_MODULE_CONTRACT -// PURPOSE: Wrap the actual packed ModelRolesPlugin.setup in a harness-owned plugin that shares its real context, enforces a mandatory loopback network guard, and exposes a bounded inspection/admission/permission control plane for the driver. -// SCOPE: Call the real plugin setup, acquire the same shared native snapshot runtime, register mandatory http/model/ws destination guards whose registration failure aborts setup, serve a nonce-protected 127.0.0.1 control plane that only forwards to real runtime methods, dispose every registration and runtime lease on both normal and failed setup, and bound the in-memory event queue. It never reproduces selection, policy, admission, or permission behavior; it only calls the packaged implementation. The module has no import-time side effects. +// PURPOSE: Wrap the actual packed ModelRolesPlugin.setup in a harness-owned plugin that shares its real context, enforces a mandatory loopback network guard, and exposes a bounded family-settings inspection and permission control plane for the driver. +// SCOPE: Call the real plugin setup, acquire the same shared native runtime, register mandatory http/model/ws destination guards whose registration failure aborts setup, serve a nonce-protected 127.0.0.1 control plane that only forwards allowlisted projections of real runtime methods, dispose every registration and runtime lease on both normal and failed setup, and bound the in-memory event queue. It never reproduces settings resolution or permission behavior; it only calls the packaged implementation. The module has no import-time side effects. // DEPENDS: [node:crypto, node:fs, node:http, scripts/e2e-v2/host.ts] // LINKS: [M-E2E-V2-HARNESS, V-M-E2E-V2-HARNESS, M-PLUGIN-MODEL-ROLES] // ROLE: SCRIPT @@ -21,12 +21,13 @@ // assertOwnedOrigin - Assert an outgoing request URL stays on the owned loopback fixture origin. // sendJson - Write a JSON response with its status and content length. // readBody - Read and JSON-parse a bounded request body. +// isRecord - True when a value is a non-array object. // createControlHandler - Build the nonce-protected handler that only forwards to real runtime methods. // closeServer - Close an owned fixture HTTP server and resolve once it has stopped. // END_MODULE_MAP // // START_CHANGE_SUMMARY -// LAST_CHANGE: [C-OPENCODE-V2-NATIVE T-003 correction - Made the loopback destination guard mandatory, used native Registration.dispose, released the runtime lease, and ran cleanup on failed setup.] +// LAST_CHANGE: [C-SESSION-OWNED-SETTINGS T-006 - The control plane forwards to the family-settings service (/settings, /family) instead of the removed admission/snapshot endpoints.] // END_CHANGE_SUMMARY import { randomBytes } from "node:crypto"; @@ -48,15 +49,10 @@ export interface HarnessRuntime { readonly location: { readonly directory: string }; readonly identity: unknown; }; - readonly snapshots: { - captures(): Promise; - variants(familyId?: string): Promise; + readonly settings: { familyOf(sessionID: string): Promise; - policy(sessionID: string): Promise; - configFor(sessionID: string): Promise; - hasStaged(familyId: string): Promise; + resolve(sessionID: string): Promise; }; - admitWorkload(request: Record): Promise; client(): Promise; readonly permissions: { request(input: Record, options?: Record): Promise; @@ -115,6 +111,11 @@ function assertOwnedOrigin(rawUrl: string, providerOrigin: string, label: string // END_BLOCK_GUARDS // START_BLOCK_CONTROL_PLANE +/** True when a value is a non-array object. */ +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + function sendJson(response: ServerResponse, status: number, payload: unknown): void { const text = JSON.stringify(payload); response.writeHead(status, { @@ -184,36 +185,25 @@ function createControlHandler(input: { }); return; } - if (request.method === "GET" && url.pathname === "/captures") { - sendJson(response, 200, { data: await runtime.snapshots.captures() }); - return; - } - if (request.method === "GET" && url.pathname === "/variants") { - sendJson(response, 200, { - data: await runtime.snapshots.variants( - sessionID === undefined ? undefined : await runtime.snapshots.familyOf(sessionID), - ), - }); - return; - } if (request.method === "GET" && url.pathname === "/family") { if (sessionID === undefined) throw new Error("missing sessionID"); - sendJson(response, 200, { familyId: await runtime.snapshots.familyOf(sessionID) }); + sendJson(response, 200, { familyId: await runtime.settings.familyOf(sessionID) }); return; } - if (request.method === "GET" && url.pathname === "/policy") { + if (request.method === "GET" && url.pathname === "/settings") { if (sessionID === undefined) throw new Error("missing sessionID"); - const familyId = await runtime.snapshots.familyOf(sessionID); + const familyId = await runtime.settings.familyOf(sessionID); + const view = (await runtime.settings.resolve(sessionID)) as + | Record + | undefined; + // Allowlisted projection only: never expose live configuration + // credentials or document contents through the control plane. + const settings = isRecord(view?.settings) ? view?.settings : undefined; sendJson(response, 200, { familyId, - capture: (await runtime.snapshots.policy(sessionID)) ?? null, - }); - return; - } - if (request.method === "GET" && url.pathname === "/config") { - if (sessionID === undefined) throw new Error("missing sessionID"); - sendJson(response, 200, { - capture: (await runtime.snapshots.configFor(sessionID)) ?? null, + source: typeof view?.source === "string" ? view.source : null, + settingsId: typeof view?.settingsId === "string" ? view.settingsId : null, + settings: settings ?? null, }); return; } @@ -230,22 +220,6 @@ function createControlHandler(input: { sendJson(response, 200, { ok: true, instanceId: runtime.runtime.instanceId }); return; } - if (request.method === "POST" && url.pathname === "/admit") { - const body = (await readBody(request)) as Record; - const outcome = await runtime.admitWorkload({ - sessionID: body.sessionID, - directory: runtime.runtime.location.directory, - location: runtime.runtime.identity, - ...(body.explicit === undefined ? {} : { explicit: body.explicit }), - ...(body.selectionOverride === undefined - ? {} - : { selectionOverride: body.selectionOverride }), - ...(body.workload === undefined ? {} : { workload: body.workload }), - ...(body.force === undefined ? {} : { force: body.force }), - }); - sendJson(response, 200, { outcome }); - return; - } if (request.method === "POST" && url.pathname === "/permission") { const body = (await readBody(request)) as { sessionID?: string; diff --git a/scripts/e2e-v2/host.ts b/scripts/e2e-v2/host.ts index 049162cb..93fa9bce 100644 --- a/scripts/e2e-v2/host.ts +++ b/scripts/e2e-v2/host.ts @@ -78,13 +78,20 @@ import { VERIFIED_HOST_VERSION } from "../../src/lib/host-compat.ts"; /** OpenCode host version under test: VVOC_HOST_VERSION, else the newest verified host. */ export const PINNED_HOST_VERSION = process.env.VVOC_HOST_VERSION ?? VERIFIED_HOST_VERSION; -/** SHA-256 of the host binary under test; override with VVOC_E2E_V2_HOST_SHA256. */ +/** + * SHA-256 of the host binary under test: the published 2.2.5 CLI binary + * (`@opencode/cli` 2.2.5, `bin/opencode.exe`), matching the newest verified + * entry in src/hosts.json. Override with VVOC_E2E_V2_HOST_SHA256. + */ export const PINNED_HOST_SHA256 = process.env.VVOC_E2E_V2_HOST_SHA256 ?? - "10d405161d8b9595f4a2ec31254e961969e6ca3f8239ac9bd55b6ff6431dc24f"; -/** Native host source commit recorded in evidence; override with VVOC_E2E_V2_SOURCE_COMMIT. */ -export const PINNED_SOURCE_COMMIT = - process.env.VVOC_E2E_V2_SOURCE_COMMIT ?? "cd9a14a6b688d4021bee381dfd39d2cef9c0f862"; + "8d060e1d2fbb9bb44cf09ae6ed1f3f6a7cff070b5227e2f90970132b23a92050"; +/** + * Native host source commit recorded in evidence when known; override with + * VVOC_E2E_V2_SOURCE_COMMIT. The published 2.2.5 binary carries no verifiable + * source-commit metadata, so nothing is claimed by default. + */ +export const PINNED_SOURCE_COMMIT = process.env.VVOC_E2E_V2_SOURCE_COMMIT ?? ""; /** Host binary under test: VVOC_E2E_V2_HOST, else the version-pinned seam host location. */ export const DEFAULT_HOST_BINARY = process.env.VVOC_E2E_V2_HOST ?? diff --git a/scripts/e2e-v2/parity.json b/scripts/e2e-v2/parity.json index 16aff53d..42b36d0b 100644 --- a/scripts/e2e-v2/parity.json +++ b/scripts/e2e-v2/parity.json @@ -25,55 +25,55 @@ "surface": "Model-roles plugin import, activation, toggle, idle quiescence, cleanup", "phase": "T-003", "status": "implemented-core", - "acceptance": "Real ModelRolesPlugin.setup runs via the shared context; idle registry has no captures or staged candidates and no reload loop.", + "acceptance": "Real ModelRolesPlugin.setup runs via the shared context; the settings service resolves without reload loops and stays bounded.", "command": "bun scripts/e2e-v2.ts --core" }, { - "id": "plugin.model-roles.admission", - "surface": "Awaited first accepted prompt admission", + "id": "plugin.model-roles.birth", + "surface": "Lazy family-settings birth on first resolution", "phase": "T-003", "status": "implemented-core", - "acceptance": "A rejected preparation after the prompt hook binds nothing; the later valid prompt binds a durable family policy and dispatches the captured payload.", + "acceptance": "A location-only family root births a version-1 settings record into session metadata on first resolution and serves it from metadata thereafter.", "command": "bun scripts/e2e-v2.ts --core" }, { - "id": "plugin.model-roles.rollback", - "surface": "Rejected/admission rollback without binding", + "id": "plugin.model-roles.fail-soft", + "surface": "Rejected preparation dispatches nothing; resolution never fails a request", "phase": "T-003", "status": "implemented-core", - "acceptance": "Preparation failure leaves the family unbound instead of publishing the staged candidate.", + "acceptance": "A preparation rejection dispatches zero provider requests; the accepted turn dispatches once and repeated settings resolution is stable with no per-request rewrite.", "command": "bun scripts/e2e-v2.ts --core" }, { "id": "plugin.model-roles.variants", - "surface": "Captured source variants on the same real model", + "surface": "Native role variants on the same real model", "phase": "T-003", "status": "implemented-core", - "acceptance": "One model dispatches two distinct qualified payloads (override/high and plain/minimal); final registry readback exposes the family-qualified variants.", + "acceptance": "The root dispatches its default-role payload (override/high) and the auxiliary child dispatches the fast-role payload (plain/minimal); the final registry readback exposes the configured variant without fabricated entries.", "command": "bun scripts/e2e-v2.ts --core" }, { "id": "plugin.model-roles.policy", - "surface": "Bound family policy versus changed configuration", + "surface": "Session-owned settings versus changed configuration", "phase": "T-003", "status": "implemented-core", - "acceptance": "A config change binds new work to a new snapshot while an already-bound family keeps its captured variant.", + "acceptance": "A config change births new families with the new role settings while an already-born family keeps resolving its recorded settings and native model.", "command": "bun scripts/e2e-v2.ts --core" }, { "id": "plugin.model-roles.synthetic", - "surface": "Owned generate/synthetic pre-admission and raw-unbound refusal", + "surface": "Native generate/synthetic without any admission gateway", "phase": "T-003", "status": "implemented-core", - "acceptance": "Pre-admission publishes an owned family before native session.generate/session.synthetic, which then dispatch the captured payload; raw unbound generate and synthetic produce zero provider dispatch and no capture.", + "acceptance": "session.generate/session.synthetic complete natively with no pre-binding step and dispatch the session's role variant payload; the family settings resolve for both.", "command": "bun scripts/e2e-v2.ts --core" }, { "id": "plugin.model-roles.auxiliary", - "surface": "Auxiliary title child bound to the family", + "surface": "Auxiliary title child bound to the family settings", "phase": "T-003", "status": "implemented-core", - "acceptance": "A persistent native child session is created with parentID, vvocAuxiliary kind/role metadata, and the same family capture prefix; exactly one provider dispatch is attributed to the parent, proving the native title dispatch was suppressed.", + "acceptance": "A persistent native child session is created with parentID, vvocAuxiliary kind/role metadata, and the family fast-role model; exactly one provider dispatch is attributed to the parent, proving the native title dispatch was suppressed.", "command": "bun scripts/e2e-v2.ts --core" }, { @@ -81,7 +81,7 @@ "surface": "Child/fork/worktree family lineage resolution", "phase": "T-003", "status": "implemented-core", - "acceptance": "The auxiliary child, a native fork with empty parentID and fork.sessionID, and a session moved into a native worktree all resolve the host-verified root family capture and dispatch its payload before dispatch.", + "acceptance": "The auxiliary child, a native fork with empty parentID and fork.sessionID, and a session moved into a native worktree all resolve the root family settings record through host-verified lineage.", "command": "bun scripts/e2e-v2.ts --core" }, { @@ -89,7 +89,7 @@ "surface": "Explicit-at-create and explicit switch selection provenance", "phase": "T-003", "status": "implemented-core", - "acceptance": "An explicit selection at create and an explicit switch before first binding are preserved over the configured default and dispatched in the provider payload.", + "acceptance": "An explicit selection at create and an explicit switch before first work are preserved over the configured default and dispatched in the provider payload; vvoc never rewrites them.", "command": "bun scripts/e2e-v2.ts --core" }, { @@ -97,31 +97,31 @@ "surface": "Native fork with empty parentID and fork.sessionID", "phase": "T-003", "status": "implemented-core", - "acceptance": "A native fork records fork.sessionID and no parentID; family lookup and the dispatched payload follow the root family.", + "acceptance": "A native fork records fork.sessionID and no parentID; lineage lookup and the resolved settings record follow the root family.", "command": "bun scripts/e2e-v2.ts --core" }, { - "id": "runtime.admission.ordering", - "surface": "First-accepted versus first-staged input ordering", + "id": "runtime.settings.stability", + "surface": "Settings birth stability under an explicit switch race", "phase": "T-003", "status": "implemented-core", - "acceptance": "With two differing-policy inputs staged on one family, the first native-accepted input binds; a fixture preparation delay after the real prompt hook controls ordering without implementing admission.", + "acceptance": "With a fixture preparation delay after the real prompt hook, the birth happens once from the pre-switch config and a later explicit model switch rewrites neither the record nor the session model.", "command": "bun scripts/e2e-v2.ts --core" }, { - "id": "runtime.admission.owned", - "surface": "Owned pre-admission gateway and prompt-candidate separation", + "id": "runtime.settings.resolve", + "surface": "Family-wide resolution: lineage root metadata over live config", "phase": "T-003", "status": "implemented-core", - "acceptance": "admitWorkload mints its own owned candidate and publishes before native model resolution; it never publishes a prompt candidate.", + "acceptance": "Every session resolves its family root's metadata record (children carry none of their own); a family without a record resolves the live configuration fail-soft.", "command": "bun scripts/e2e-v2.ts --core" }, { - "id": "runtime.snapshot-persist", - "surface": "Durable family capture persistence and restart replay", + "id": "runtime.settings-persist", + "surface": "Durable family settings persistence and restart replay", "phase": "T-003", "status": "implemented-core", - "acceptance": "Bound captures persist under the isolated XDG data directory; after a real host restart the same persisted session rebinds its original snapshot-qualified variant.", + "acceptance": "Family settings persist in the host session store (opencode.db); after a real host restart the same persisted record drives the same resolution byte-identically.", "command": "bun scripts/e2e-v2.ts --core" }, { @@ -129,7 +129,7 @@ "surface": "Durable replay after service restart", "phase": "T-003", "status": "implemented-core", - "acceptance": "A second owned host instance replays the persisted family capture for an existing session instead of adopting the current policy silently.", + "acceptance": "A second owned host instance replays the persisted family settings for existing root and auxiliary sessions instead of rebirthing them, while a fresh family births the same effective config.", "command": "bun scripts/e2e-v2.ts --core" }, { diff --git a/src/cli.ts b/src/cli.ts index b858e637..fb4f24a1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,11 +1,11 @@ #!/usr/bin/env bun // FILE: src/cli.ts -// VERSION: 0.2.10 +// VERSION: 0.3.0 // START_MODULE_CONTRACT // PURPOSE: Assemble and run the vvoc CLI entrypoint. // SCOPE: Package version lookup, top-level command registration including the lint command, and main command execution. -// DEPENDS: [citty, src/commands/analytics.ts, src/commands/completion.ts, src/commands/config.ts, src/commands/doctor.ts, src/commands/guardian.ts, src/commands/init.ts, src/commands/install.ts, src/commands/launch.ts, src/commands/orchestration.ts, src/commands/lint.ts, src/commands/patch-provider.ts, src/commands/preset.ts, src/commands/plugin.ts, src/commands/role.ts, src/commands/status.ts, src/commands/sync.ts, src/commands/telegram.ts, src/commands/upgrade.ts, src/commands/version.ts, src/lib/package.ts] +// DEPENDS: [citty, src/commands/analytics.ts, src/commands/completion.ts, src/commands/config.ts, src/commands/doctor.ts, src/commands/guardian.ts, src/commands/init.ts, src/commands/install.ts, src/commands/launch.ts, src/commands/orchestration.ts, src/commands/lint.ts, src/commands/patch-provider.ts, src/commands/preset.ts, src/commands/plugin.ts, src/commands/role.ts, src/commands/session.ts, src/commands/status.ts, src/commands/sync.ts, src/commands/telegram.ts, src/commands/upgrade.ts, src/commands/version.ts, src/lib/package.ts] // LINKS: [M-CLI-COMMANDS] // ROLE: SCRIPT // MAP_MODE: LOCALS @@ -17,7 +17,7 @@ // END_MODULE_MAP // // START_CHANGE_SUMMARY -// LAST_CHANGE: [C-TELEGRAM-TOPIC-HYGIENE T-008 - Registered the telegram command group in the CLI tree.] +// LAST_CHANGE: [C-SESSION-OWNED-SETTINGS T-003 - Registered the session command group for explicit family-settings changes.] // END_CHANGE_SUMMARY import { defineCommand, runMain } from "citty"; @@ -35,6 +35,7 @@ import patchProvider from "./commands/patch-provider.js"; import preset from "./commands/preset.js"; import plugin from "./commands/plugin.js"; import role from "./commands/role.js"; +import session from "./commands/session.js"; import status from "./commands/status.js"; import sync from "./commands/sync.js"; import telegram from "./commands/telegram.js"; @@ -67,6 +68,7 @@ const main = defineCommand({ preset, plugin, role, + session, status, sync, telegram, diff --git a/src/commands/plugin-toggle.ts b/src/commands/plugin-toggle.ts index 3ff7f2ee..5681c7dc 100644 --- a/src/commands/plugin-toggle.ts +++ b/src/commands/plugin-toggle.ts @@ -1,5 +1,5 @@ // FILE: src/commands/plugin-toggle.ts -// VERSION: 1.0.0 +// VERSION: 1.1.0 // START_MODULE_CONTRACT // PURPOSE: Provide `vvoc plugin enable ` and `vvoc plugin disable ` subcommands that mutate the `plugins` section in scoped vvoc.json layers. // SCOPE: Plugin name validation, global/project vvoc.json bootstrap, read/write, and CLI output. @@ -15,14 +15,13 @@ // END_MODULE_MAP // // START_CHANGE_SUMMARY -// LAST_CHANGE: [C-SNAPSHOT-ANCHORING-REDESIGN T-006 - Added the --force/--session flags that request a rebind of running families after toggling a plugin.] +// LAST_CHANGE: [C-SESSION-OWNED-SETTINGS T-004 - Removed the --force/--session rebind flags; toggles now target new sessions and explicit session-settings commands change running families.] // END_CHANGE_SUMMARY import { defineCommand } from "citty"; import { readFile, writeFile } from "node:fs/promises"; import { resolvePaths, syncVvocConfig } from "../lib/opencode.js"; import { PLUGIN_TOGGLE_NAMES } from "../lib/plugin-toggle-config.js"; -import { requestRebind } from "../lib/rebind-request.js"; import type { Scope } from "../lib/opencode.js"; // START_BLOCK_TOGGLE_PLUGIN @@ -67,22 +66,6 @@ async function togglePlugin( } // END_BLOCK_TOGGLE_PLUGIN -async function requestForceRebind(force: unknown, session: unknown): Promise { - const sessionId = typeof session === "string" && session.trim() ? session.trim() : undefined; - const request = await requestRebind({ - force: force === true, - directory: process.cwd(), - sessionId, - }); - if (request !== undefined) { - console.log( - request.sessionId === undefined - ? "Rebind requested for every active session family in this project." - : `Rebind requested for session ${request.sessionId}.`, - ); - } -} - // START_BLOCK_ENABLE_COMMAND export const enableCommand = defineCommand({ meta: { @@ -105,14 +88,6 @@ export const enableCommand = defineCommand({ type: "string", description: "Override the global config home.", }, - force: { - type: "boolean" as const, - description: "Also rebind running session families in this project.", - }, - session: { - type: "string" as const, - description: "With --force, rebind only this session family.", - }, }, async run({ args }) { const pluginName = args.plugin as string; @@ -130,7 +105,6 @@ export const enableCommand = defineCommand({ try { const message = await togglePlugin(pluginName, true, scope, cwd, configDir); console.log(message); - await requestForceRebind(args.force, args.session); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); @@ -161,14 +135,6 @@ export const disableCommand = defineCommand({ type: "string", description: "Override the global config home.", }, - force: { - type: "boolean" as const, - description: "Also rebind running session families in this project.", - }, - session: { - type: "string" as const, - description: "With --force, rebind only this session family.", - }, }, async run({ args }) { const pluginName = args.plugin as string; @@ -186,7 +152,6 @@ export const disableCommand = defineCommand({ try { const message = await togglePlugin(pluginName, false, scope, cwd, configDir); console.log(message); - await requestForceRebind(args.force, args.session); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); diff --git a/src/commands/preset.test.ts b/src/commands/preset.test.ts index aa2af957..3fb60d85 100644 --- a/src/commands/preset.test.ts +++ b/src/commands/preset.test.ts @@ -3,7 +3,7 @@ // START_MODULE_CONTRACT // PURPOSE: Tests for M-CLI-PRESET - declarative named preset workflows. // SCOPE: Built-in profile mappings, preset rendering, atomic role/profile application, no-opencode rewrite guarantees, section preservation, invalid-write safety, and CLI output paths. -// DEPENDS: [bun:test, node:fs/promises, node:os, node:path, src/commands/preset.ts, src/commands/registered-cli.test-helpers.ts, src/lib/opencode.ts, src/lib/rebind-request.ts, src/lib/vvoc-config.ts] +// DEPENDS: [bun:test, node:fs/promises, node:os, node:path, src/commands/preset.ts, src/commands/registered-cli.test-helpers.ts, src/lib/opencode.ts, src/lib/vvoc-config.ts] // LINKS: [M-CLI-PRESET, M-ORCHESTRATION-PROFILES, V-M-CLI-PRESET] // ROLE: TEST // MAP_MODE: LOCALS @@ -14,7 +14,7 @@ // END_MODULE_MAP // // START_CHANGE_SUMMARY -// LAST_CHANGE: [v0.6.0 - Routed registered-CLI subprocess coverage through the shared bounded, retried spawn helper with a documented per-test budget.] +// LAST_CHANGE: [C-SESSION-OWNED-SETTINGS T-004 - Dropped the force-rebind marker coverage together with the removed preset --force/--session flags.] // END_CHANGE_SUMMARY import { describe, expect, test } from "bun:test"; @@ -22,7 +22,6 @@ import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promise import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { applyPreset, formatPreset, listConfiguredPresets, resolvePreset } from "./preset.js"; -import { listRebindRequests, requestRebind } from "../lib/rebind-request.js"; import { readVvocConfig, resolvePaths } from "../lib/opencode.js"; import { createDefaultVvocConfig, renderVvocConfig } from "../lib/vvoc-config.js"; import { REGISTERED_CLI_TEST_TIMEOUT_MS, runRegisteredCli } from "./registered-cli.test-helpers.js"; @@ -606,7 +605,7 @@ describe("applyPreset", () => { expect(result.stdout).toContain("orchestration: kept (balanced)"); expect(result.stdout).toContain(`Target: ${paths.vvocConfigPath}`); expect(result.stdout).toContain( - "On OpenCode v1, restart OpenCode to apply the changed roles or orchestration profile. On OpenCode v2, the config watcher applies them without a restart; running sessions stay anchored to their starting preset.", + "The config watcher applies changed roles or the orchestration profile to new sessions; running session families keep the settings they were born with until an explicit `vvoc session set` or `/vv-settings` change.", ); const vvocConfig = await readVvocConfig(paths); expect(vvocConfig?.roles.default).toBe("zai-coding-plan/glm-5.3-flash#max"); @@ -667,24 +666,3 @@ describe("applyPreset", () => { REGISTERED_CLI_TEST_TIMEOUT_MS, ); }); - -describe("preset force rebind request", () => { - test("writes a project-scoped marker only when forced", async () => { - const dataDir = await mkdtemp(join(tmpdir(), "vvoc-rebind-")); - try { - expect(await requestRebind({ force: false, directory: "/p", dataDir })).toBeUndefined(); - const request = await requestRebind({ - force: true, - directory: "/p", - sessionId: "ses_1", - dataDir, - }); - expect(request?.sessionId).toBe("ses_1"); - const live = await listRebindRequests({ dataDir }); - expect(live).toHaveLength(1); - expect(live[0]?.directory.endsWith("/p")).toBe(true); - } finally { - await rm(dataDir, { recursive: true, force: true }); - } - }); -}); diff --git a/src/commands/preset.ts b/src/commands/preset.ts index 2313429d..41ac7301 100644 --- a/src/commands/preset.ts +++ b/src/commands/preset.ts @@ -1,5 +1,5 @@ // FILE: src/commands/preset.ts -// VERSION: 0.4.2 +// VERSION: 0.5.0 // START_MODULE_CONTRACT // PURPOSE: List, show, and atomically apply declarative role and orchestration presets from scoped vvoc.json layers. // SCOPE: Effective/global/project preset lookup, preset rendering, and conservative role plus optional orchestration writes. @@ -20,7 +20,7 @@ // END_MODULE_MAP // // START_CHANGE_SUMMARY -// LAST_CHANGE: [C-SNAPSHOT-ANCHORING-REDESIGN T-006 - Added the --force/--session flags that request a rebind of running families after a preset apply.] +// LAST_CHANGE: [C-SESSION-OWNED-SETTINGS T-004 - Removed the --force/--session rebind flags; running families keep their birth settings and only explicit session-settings commands change them.] // END_CHANGE_SUMMARY import { defineCommand } from "citty"; @@ -31,7 +31,6 @@ import { type ConfigReadScope, type ConfigWriteScope, } from "../lib/config-layers.js"; -import { requestRebind } from "../lib/rebind-request.js"; import { parseModelSelection } from "../lib/model-roles.js"; import { resolvePaths } from "../lib/opencode.js"; import { @@ -98,14 +97,6 @@ export default defineCommand({ preset: presetArg, scope: scopeArg, "config-dir": configDirArg, - force: { - type: "boolean" as const, - description: "Also rebind running session families in this project to the new preset.", - }, - session: { - type: "string" as const, - description: "With --force, rebind only this session family instead of every active family.", - }, }, async run({ args }) { await runPresetCommand(args); @@ -221,9 +212,6 @@ async function runPresetCommand(args: Record): Promise { const presetName = typeof args.preset === "string" ? args.preset.trim() : ""; const configDir = typeof args["config-dir"] === "string" ? args["config-dir"] : undefined; const readScope = resolveReadScope(args.scope); - const force = args.force === true; - const forceSession = - typeof args.session === "string" && args.session.trim() ? args.session.trim() : undefined; if (!command || command === "list") { if (presetName) { @@ -282,24 +270,12 @@ async function runPresetCommand(args: Record): Promise { ` orchestration: ${applied.orchestration.action} (${applied.orchestration.profile})`, ); console.log(`Target: ${applied.path}`); - const request = await requestRebind({ - force, - directory: process.cwd(), - sessionId: forceSession, - }); - if (request !== undefined) { - console.log( - request.sessionId === undefined - ? "Rebind requested for every active session family in this project." - : `Rebind requested for session ${request.sessionId}.`, - ); - } if ( applied.changes.some((change) => change.action === "updated") || applied.orchestration.action === "updated" ) { console.log( - "On OpenCode v1, restart OpenCode to apply the changed roles or orchestration profile. On OpenCode v2, the config watcher applies them without a restart; running sessions stay anchored to their starting preset.", + "The config watcher applies changed roles or the orchestration profile to new sessions; running session families keep the settings they were born with until an explicit `vvoc session set` or `/vv-settings` change.", ); } } diff --git a/src/commands/role.ts b/src/commands/role.ts index d5327ddb..3bd15532 100644 --- a/src/commands/role.ts +++ b/src/commands/role.ts @@ -1,5 +1,5 @@ // FILE: src/commands/role.ts -// VERSION: 0.1.0 +// VERSION: 0.2.0 // START_MODULE_CONTRACT // PURPOSE: Manage canonical vvoc model role assignments in scoped vvoc.json layers. // SCOPE: Effective/global/project role listing, scoped set/unset writes, built-in role protection, and normalized model parsing. @@ -17,7 +17,7 @@ // END_MODULE_MAP // // START_CHANGE_SUMMARY -// LAST_CHANGE: [C-SNAPSHOT-ANCHORING-REDESIGN T-006 - Added the --force/--session flags that request a rebind of running families after a role change.] +// LAST_CHANGE: [C-SESSION-OWNED-SETTINGS T-004 - Removed the --force/--session rebind flags; role changes now target new sessions and explicit session-settings commands change running families.] // END_CHANGE_SUMMARY import { defineCommand } from "citty"; @@ -27,7 +27,6 @@ import { type ConfigReadScope, type ConfigWriteScope, } from "../lib/config-layers.js"; -import { requestRebind } from "../lib/rebind-request.js"; import { BUILTIN_ROLE_NAMES, SEEDED_ROLE_NAMES, parseModelSelection } from "../lib/model-roles.js"; import { readVvocConfig, resolvePaths, syncVvocConfig } from "../lib/opencode.js"; import { renderVvocConfig, type VvocConfig } from "../lib/vvoc-config.js"; @@ -58,32 +57,6 @@ const writeScopeArg = { description: "Write global config or project-local config.", }; -const forceArg = { - type: "boolean" as const, - description: "Also rebind running session families in this project.", -}; - -const sessionArg = { - type: "string" as const, - description: "With --force, rebind only this session family.", -}; - -async function requestForceRebind(force: unknown, session: unknown): Promise { - const sessionId = typeof session === "string" && session.trim() ? session.trim() : undefined; - const request = await requestRebind({ - force: force === true, - directory: process.cwd(), - sessionId, - }); - if (request !== undefined) { - console.log( - request.sessionId === undefined - ? "Rebind requested for every active session family in this project." - : `Rebind requested for session ${request.sessionId}.`, - ); - } -} - const readScopeArg = { type: "enum" as const, options: ["global", "project", "effective"], @@ -138,8 +111,6 @@ const roleSet = defineCommand({ model: modelArg, scope: writeScopeArg, "config-dir": configDirArg, - force: forceArg, - session: sessionArg, }, async run({ args }) { const roleId = normalizeRoleId(args.role, "set"); @@ -152,7 +123,6 @@ const roleSet = defineCommand({ }); console.log(`${result.action}: ${roleId} -> ${modelSelection} (${result.path})`); - await requestForceRebind(args.force, args.session); }, }); @@ -165,8 +135,6 @@ const roleUnset = defineCommand({ role: roleArg, scope: writeScopeArg, "config-dir": configDirArg, - force: forceArg, - session: sessionArg, }, async run({ args }) { const roleId = normalizeRoleId(args.role, "unset"); @@ -176,7 +144,6 @@ const roleUnset = defineCommand({ }); console.log(`${result.action}: ${roleId} (${result.path})`); - await requestForceRebind(args.force, args.session); }, }); diff --git a/src/commands/session.test.ts b/src/commands/session.test.ts new file mode 100644 index 00000000..07418eb0 --- /dev/null +++ b/src/commands/session.test.ts @@ -0,0 +1,334 @@ +// FILE: src/commands/session.test.ts +// VERSION: 1.0.0 +// START_MODULE_CONTRACT +// PURPOSE: Tests for M-CLI-SESSION - explicit session-settings changes through lineage-rooted metadata read-modify-write. +// SCOPE: Shared action parsing, family isolation (only the named family changes), foreign-metadata preservation on every write, preset/profile/role/plugin/pull-defaults application, and summary rendering. +// DEPENDS: [bun:test, node:fs/promises, node:os, node:path, src/commands/session.ts, src/lib/vvoc-config.ts, src/runtime/session-settings.ts] +// LINKS: [M-CLI-SESSION, V-M-CLI-SESSION] +// ROLE: TEST +// MAP_MODE: LOCALS +// END_MODULE_CONTRACT +// +// START_MODULE_MAP +// [test scenarios] - Session-settings behavior coverage is expressed through module-level tests. +// FakeSession - One fake host session row with optional parent, fork, and metadata. +// FakeHost - In-memory host double implementing the client seam with replace-semantics metadata updates. +// writeProjectConfig - Write the fixture project's vvoc.json through the canonical renderer. +// makeProject - Create an isolated fixture project directory. +// tempDirs - Temporary directories removed after each test. +// END_MODULE_MAP +// +// START_CHANGE_SUMMARY +// LAST_CHANGE: [C-SESSION-OWNED-SETTINGS T-004 - Covered the shared action parser, family isolation, the RMW foreign-key property, and every explicit settings action.] +// END_CHANGE_SUMMARY + +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + applySessionSettingsAction, + describeFamilySettings, + parseSessionSettingsAction, + readFamilySettingsViaClient, +} from "./session.js"; +import type { SessionSettingsClient } from "../runtime/session-settings.js"; +import { readSettingsFromMetadata } from "../runtime/session-settings.js"; +import { createDefaultVvocConfig, renderVvocConfig } from "../lib/vvoc-config.js"; + +// START_BLOCK_FAKE_HOST +const tempDirs: string[] = []; +afterEach(async () => { + for (const dir of tempDirs.splice(0)) { + await rm(dir, { recursive: true, force: true }).catch(() => undefined); + } +}); + +interface FakeSession { + readonly id: string; + parentID?: string; + forkSessionID?: string; + metadata?: Record; +} + +class FakeHost { + readonly sessions = new Map(); + readonly updates: Array<{ sessionID: string; metadata: Record }> = []; + + add(session: FakeSession): void { + this.sessions.set(session.id, { ...session }); + } + + client(): SessionSettingsClient { + return { + session: { + get: async ({ sessionID }) => { + const session = this.sessions.get(sessionID); + if (session === undefined) throw new Error(`unknown session ${sessionID}`); + return { + id: session.id, + ...(session.parentID === undefined ? {} : { parentID: session.parentID }), + ...(session.forkSessionID === undefined + ? {} + : { forkSessionID: session.forkSessionID }), + ...(session.metadata === undefined ? {} : { metadata: session.metadata }), + }; + }, + update: async ({ + sessionID, + metadata, + }: { + sessionID: string; + metadata?: Record; + }) => { + const session = this.sessions.get(sessionID); + if (session === undefined) throw new Error(`unknown session ${sessionID}`); + // Native semantics: the whole metadata map is replaced. + this.sessions.set(sessionID, { ...session, metadata }); + this.updates.push({ sessionID, metadata: { ...metadata } }); + return { id: sessionID }; + }, + }, + }; + } +} +// END_BLOCK_FAKE_HOST + +async function writeProjectConfig( + dir: string, + mutate: (config: ReturnType) => void, +): Promise { + await mkdir(join(dir, ".vvoc"), { recursive: true }); + const config = createDefaultVvocConfig(); + mutate(config); + await writeFile(join(dir, ".vvoc", "vvoc.json"), renderVvocConfig(config), "utf8"); + return dir; +} + +function makeProject(): Promise { + return mkdtemp(join(tmpdir(), "vvoc-session-set-")); +} + +describe("parseSessionSettingsAction", () => { + test("parses every shared text action", () => { + expect(parseSessionSettingsAction("preset vv-zai")).toEqual({ + kind: "preset", + name: "vv-zai", + }); + expect(parseSessionSettingsAction("profile orchestrated")).toEqual({ + kind: "profile", + profile: "orchestrated", + }); + expect(parseSessionSettingsAction("role smart prov/m2#high")).toEqual({ + kind: "role-set", + roleId: "smart", + model: "prov/m2#high", + }); + expect(parseSessionSettingsAction("role unset smart")).toEqual({ + kind: "role-unset", + roleId: "smart", + }); + expect(parseSessionSettingsAction("plugin secrets-redaction off")).toEqual({ + kind: "plugin", + plugin: "secrets-redaction", + enabled: false, + }); + expect(parseSessionSettingsAction("pull-defaults")).toEqual({ kind: "pull-defaults" }); + }); + + test("rejects empty and unknown actions with the accepted syntax", () => { + expect(() => parseSessionSettingsAction("")).toThrow("settings action required"); + expect(() => parseSessionSettingsAction("freeze")).toThrow(/unknown settings action/); + expect(() => parseSessionSettingsAction("plugin secrets-redaction")).toThrow(/usage: plugin/); + }); +}); + +describe("applySessionSettingsAction", () => { + test("changes only the named family and preserves foreign metadata keys", async () => { + const host = new FakeHost(); + host.add({ id: "root_a", metadata: { user: { note: "keep me" } } }); + host.add({ id: "child_a", parentID: "root_a" }); + host.add({ id: "root_b", metadata: { other: 1 } }); + + const project = await makeProject(); + try { + const result = await applySessionSettingsAction({ + client: host.client(), + sessionID: "child_a", + action: { kind: "profile", profile: "orchestrated" }, + cwd: project, + }); + expect(result.rootID).toBe("root_a"); + // Family A was written through a child id, exactly once, at the root. + expect(host.updates).toHaveLength(1); + expect(host.updates[0]?.sessionID).toBe("root_a"); + // The foreign metadata key survived the replace-semantics write. + expect(host.sessions.get("root_a")?.metadata?.user).toEqual({ note: "keep me" }); + expect(readSettingsFromMetadata(host.sessions.get("root_a")?.metadata)).toMatchObject({ + settings: { profile: "orchestrated" }, + }); + // Family B was not touched. + expect(host.sessions.get("root_b")?.metadata).toEqual({ other: 1 }); + } finally { + await rm(project, { recursive: true, force: true }); + } + }); + + test("preset resolves the configured preset's roles and profile", async () => { + const host = new FakeHost(); + host.add({ id: "ses_root" }); + const project = await makeProject(); + try { + await writeProjectConfig(project, (config) => { + config.presets["vv-test"] = { + description: "test preset", + agents: { smart: "prov/m-smart#high", fast: "prov/m-fast" }, + orchestration: { profile: "orchestrated" }, + }; + }); + const result = await applySessionSettingsAction({ + client: host.client(), + sessionID: "ses_root", + action: { kind: "preset", name: "vv-test" }, + cwd: project, + }); + expect(result.settings.preset).toBe("vv-test"); + expect(result.settings.profile).toBe("orchestrated"); + expect(result.settings.roles).toEqual({ + smart: "prov/m-smart#high", + fast: "prov/m-fast", + }); + } finally { + await rm(project, { recursive: true, force: true }); + } + }); + + test("unknown preset names fail without writing metadata", async () => { + const host = new FakeHost(); + host.add({ id: "ses_root" }); + const project = await makeProject(); + try { + await expect( + applySessionSettingsAction({ + client: host.client(), + sessionID: "ses_root", + action: { kind: "preset", name: "missing" }, + cwd: project, + }), + ).rejects.toThrow(/unknown preset: missing/); + expect(host.updates).toHaveLength(0); + } finally { + await rm(project, { recursive: true, force: true }); + } + }); + + test("role set and role unset update exactly one role entry", async () => { + const host = new FakeHost(); + host.add({ id: "ses_root" }); + const project = await makeProject(); + try { + const set = await applySessionSettingsAction({ + client: host.client(), + sessionID: "ses_root", + action: { kind: "role-set", roleId: "smart", model: "prov/m2#high" }, + cwd: project, + }); + expect(set.settings.roles.smart).toBe("prov/m2#high"); + const unset = await applySessionSettingsAction({ + client: host.client(), + sessionID: "ses_root", + action: { kind: "role-unset", roleId: "smart" }, + cwd: project, + }); + expect(unset.settings.roles.smart).toBeUndefined(); + } finally { + await rm(project, { recursive: true, force: true }); + } + }); + + test("invalid role ids and unknown plugin names are rejected before any write", async () => { + const host = new FakeHost(); + host.add({ id: "ses_root" }); + const project = await makeProject(); + try { + await expect( + applySessionSettingsAction({ + client: host.client(), + sessionID: "ses_root", + action: { kind: "role-set", roleId: "Bad Role", model: "prov/m" }, + cwd: project, + }), + ).rejects.toThrow(/invalid role id/); + await expect( + applySessionSettingsAction({ + client: host.client(), + sessionID: "ses_root", + action: { kind: "plugin", plugin: "not-a-plugin", enabled: true }, + cwd: project, + }), + ).rejects.toThrow(/unknown plugin/); + expect(host.updates).toHaveLength(0); + } finally { + await rm(project, { recursive: true, force: true }); + } + }); + + test("pull-defaults replaces the record with the current effective defaults", async () => { + const host = new FakeHost(); + host.add({ + id: "ses_root", + metadata: { keep: "me" }, + }); + const project = await makeProject(); + try { + const result = await applySessionSettingsAction({ + client: host.client(), + sessionID: "ses_root", + action: { kind: "pull-defaults" }, + cwd: project, + }); + expect(result.settings.preset).toBeUndefined(); + expect(result.settings.profile).toBeDefined(); + // The RMW write still preserves the foreign key. + expect(host.sessions.get("ses_root")?.metadata?.keep).toBe("me"); + } finally { + await rm(project, { recursive: true, force: true }); + } + }); + + test("readFamilySettingsViaClient resolves through lineage or undefined when unborn", async () => { + const host = new FakeHost(); + host.add({ id: "ses_root" }); + host.add({ id: "ses_child", parentID: "ses_root" }); + const client = host.client(); + expect(await readFamilySettingsViaClient({ client, sessionID: "ses_child" })).toBeUndefined(); + const project = await makeProject(); + tempDirs.push(project); + await applySessionSettingsAction({ + client, + sessionID: "ses_child", + action: { kind: "profile", profile: "single-session" }, + cwd: project, + }); + const read = await readFamilySettingsViaClient({ client, sessionID: "ses_child" }); + expect(read?.profile).toBe("single-session"); + }); +}); + +describe("describeFamilySettings", () => { + test("renders bounded deterministic summary lines", () => { + const lines = describeFamilySettings({ + version: 1, + preset: "vv-zai", + roles: { smart: "prov/m2", default: "prov/m1" }, + profile: "orchestrated", + plugins: { "secrets-redaction": true, "hashline-edit": false }, + }); + expect(lines).toEqual([ + "preset: vv-zai", + "roles: default=prov/m1, smart=prov/m2", + "profile: orchestrated", + "plugins: hashline-edit=off, secrets-redaction=on", + ]); + }); +}); diff --git a/src/commands/session.ts b/src/commands/session.ts new file mode 100644 index 00000000..716c6458 --- /dev/null +++ b/src/commands/session.ts @@ -0,0 +1,413 @@ +// FILE: src/commands/session.ts +// VERSION: 1.0.0 +// START_MODULE_CONTRACT +// PURPOSE: Provide `vvoc session set` for explicit session-owned family-settings changes through the running host, plus the shared action parsing, application, and summary helpers reused by the native TUI /vv-settings command. +// SCOPE: Discovery of the running native service, lineage-rooted read-modify-write metadata updates (preset, profile, role, plugin toggle, pull-defaults), and human-readable summaries. Config reads follow the scoped layering; credentials are never written into settings. +// DEPENDS: [citty, @opencode/client, @opencode/client/service, src/lib/config-layers.ts, src/lib/model-roles.ts, src/lib/orchestration.ts, src/lib/plugin-toggle-config.ts, src/runtime/session-settings.ts, src/runtime/types.ts] +// LINKS: [M-CLI-SESSION, M-NATIVE-RUNTIME, M-PLUGIN-CONTEXT-TUI, V-M-CLI-SESSION] +// ROLE: RUNTIME +// MAP_MODE: EXPORTS +// END_MODULE_CONTRACT +// +// START_MODULE_MAP +// SessionSettingsAction - One explicit settings change requested by name. +// parseSessionSettingsAction - Parse the shared text action syntax used by the TUI. +// applySessionSettingsAction - Resolve the family root and read-modify-write its settings key. +// describeFamilySettings - Render a settings record as bounded summary lines. +// readFamilySettingsViaClient - Read the current settings of a session's family root. +// acquireSettingsClient - Discover the running native service and build an authenticated client. +// default - Session command group with the `set` subcommand. +// END_MODULE_MAP +// +// START_CHANGE_SUMMARY +// LAST_CHANGE: [C-SESSION-OWNED-SETTINGS T-003 - Added the explicit session-settings command set: preset, profile, role, plugin toggle, and pull-defaults through a lineage-rooted metadata read-modify-write.] +// END_CHANGE_SUMMARY + +import { defineCommand } from "citty"; +import { OpenCode } from "@opencode/client"; +import { Service } from "@opencode/client/service"; +import { loadVvocConfigForRead } from "../lib/config-layers.js"; +import { parseModelSelection } from "../lib/model-roles.js"; +import { parseOrchestrationProfile } from "../lib/orchestration.js"; +import { PLUGIN_TOGGLE_NAMES } from "../lib/plugin-toggle-config.js"; +import { + readSettingsFromMetadata, + resolveFamilyRootViaClient, + settingsFromConfig, + updateFamilySettingsViaClient, + type FamilySettings, + type SessionSettingsClient, +} from "../runtime/session-settings.js"; +import { isSupportedServiceVersion } from "../runtime/types.js"; + +const ROLE_ID_PATTERN = /^[a-z][a-z0-9-]*$/; + +/** One explicit settings change requested by name. */ +export type SessionSettingsAction = + | { readonly kind: "preset"; readonly name: string } + | { readonly kind: "profile"; readonly profile: string } + | { readonly kind: "role-set"; readonly roleId: string; readonly model: string } + | { readonly kind: "role-unset"; readonly roleId: string } + | { readonly kind: "plugin"; readonly plugin: string; readonly enabled: boolean } + | { readonly kind: "pull-defaults" }; + +/** + * Parse the shared text action syntax behind `/vv-settings`: an empty input is + * a read (undefined), otherwise the first word selects the action. Unknown + * actions throw with the accepted syntax so the caller can surface it. + */ +export function parseSessionSettingsAction(raw: string | undefined): SessionSettingsAction { + const text = (raw ?? "").trim(); + if (text.length === 0) throw new Error("settings action required"); + const [head, ...rest] = text.split(/\s+/); + switch (head) { + case "preset": { + const name = rest.join(" ").trim(); + if (!name) throw new Error("usage: preset "); + return { kind: "preset", name }; + } + case "profile": { + const profile = rest.join(" ").trim(); + if (!profile) throw new Error("usage: profile "); + return { kind: "profile", profile }; + } + case "role": { + const roleId = rest[0]?.trim() ?? ""; + const model = rest.slice(1).join(" ").trim(); + if (!roleId) throw new Error("usage: role | role unset "); + if (roleId === "unset") { + const unsetId = rest[1]?.trim() ?? ""; + if (!unsetId) throw new Error("usage: role unset "); + return { kind: "role-unset", roleId: unsetId }; + } + if (!model) throw new Error("usage: role "); + return { kind: "role-set", roleId, model }; + } + case "plugin": { + const plugin = rest[0]?.trim() ?? ""; + const state = rest[1]?.trim().toLowerCase() ?? ""; + if (!plugin || (state !== "on" && state !== "off")) { + throw new Error("usage: plugin on|off"); + } + return { kind: "plugin", plugin, enabled: state === "on" }; + } + case "pull-defaults": + return { kind: "pull-defaults" }; + default: + throw new Error( + "unknown settings action; accepted: preset | profile | role | role unset | plugin on|off | pull-defaults", + ); + } +} + +function normalizeRoleId(roleId: string): string { + const normalized = roleId.trim(); + if (!ROLE_ID_PATTERN.test(normalized)) { + throw new Error( + `invalid role id \`${roleId}\`; expected lowercase letters, digits, and hyphens`, + ); + } + return normalized; +} + +function assertToggleName(plugin: string): string { + if (!PLUGIN_TOGGLE_NAMES.includes(plugin as (typeof PLUGIN_TOGGLE_NAMES)[number])) { + throw new Error( + `unknown plugin "${plugin}"; available plugins: ${PLUGIN_TOGGLE_NAMES.join(", ")}`, + ); + } + return plugin; +} + +/** Read-modify-write the settings key of a session's family root. */ +export async function applySessionSettingsAction(input: { + readonly client: SessionSettingsClient; + readonly sessionID: string; + readonly action: SessionSettingsAction; + readonly cwd?: string; + readonly configDir?: string; +}): Promise<{ readonly rootID: string; readonly settings: FamilySettings }> { + const action = input.action; + if (action.kind === "pull-defaults") { + const { config } = await loadVvocConfigForRead({ + scope: "effective", + cwd: input.cwd ?? process.cwd(), + configDir: input.configDir, + allowDefault: true, + }); + return updateFamilySettingsViaClient({ + client: input.client, + sessionID: input.sessionID, + apply: () => settingsFromConfig(config), + }); + } + return updateFamilySettingsViaClient({ + client: input.client, + sessionID: input.sessionID, + apply: (current) => applyToSettings(current, action, input), + }); +} + +async function applyToSettings( + current: FamilySettings | undefined, + action: SessionSettingsAction, + input: { readonly cwd?: string; readonly configDir?: string }, +): Promise { + const base: FamilySettings = current ?? (await settingsFromCurrentConfig(input)); + switch (action.kind) { + case "preset": { + const { config } = await loadVvocConfigForRead({ + scope: "effective", + cwd: input.cwd ?? process.cwd(), + configDir: input.configDir, + allowDefault: true, + }); + const presetName = action.name.trim(); + const preset = config.presets[presetName]; + if (preset === undefined) { + const available = Object.keys(config.presets).sort().join(", "); + throw new Error( + `unknown preset: ${presetName}. Available presets: ${available || ""}`, + ); + } + const roles: Record = {}; + for (const [roleId, rawModel] of Object.entries(preset.agents)) { + roles[normalizeRoleId(roleId)] = parseModelSelection( + typeof rawModel === "string" ? rawModel : String(rawModel), + ).normalized; + } + return { + version: 1, + preset: presetName, + roles, + profile: + preset.orchestration === undefined + ? base.profile + : parseOrchestrationProfile(preset.orchestration.profile, `preset ${presetName}`), + plugins: base.plugins, + }; + } + case "profile": + return { + ...base, + preset: base.preset, + profile: parseOrchestrationProfile(action.profile, "profile"), + }; + case "role-set": + return { + ...base, + preset: base.preset, + roles: { + ...base.roles, + [normalizeRoleId(action.roleId)]: parseModelSelection(action.model).normalized, + }, + }; + case "role-unset": { + const roleId = normalizeRoleId(action.roleId); + const { [roleId]: _removed, ...remaining } = base.roles; + return { ...base, preset: base.preset, roles: remaining }; + } + case "plugin": + return { + ...base, + preset: base.preset, + plugins: { + ...base.plugins, + [assertToggleName(action.plugin)]: action.enabled, + }, + }; + case "pull-defaults": + // Handled before apply; unreachable here. + return base; + } +} + +async function settingsFromCurrentConfig(input: { + readonly cwd?: string; + readonly configDir?: string; +}): Promise { + const { config } = await loadVvocConfigForRead({ + scope: "effective", + cwd: input.cwd ?? process.cwd(), + configDir: input.configDir, + allowDefault: true, + }); + return settingsFromConfig(config); +} + +/** Render a settings record as bounded summary lines. */ +export function describeFamilySettings(settings: FamilySettings): string[] { + const lines: string[] = []; + if (settings.preset !== undefined) lines.push(`preset: ${settings.preset}`); + const roles = Object.entries(settings.roles) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([roleId, model]) => `${roleId}=${model}`); + lines.push(roles.length === 0 ? "roles: (none)" : `roles: ${roles.join(", ")}`); + lines.push(`profile: ${settings.profile}`); + const plugins = Object.entries(settings.plugins) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, enabled]) => `${name}=${enabled ? "on" : "off"}`); + lines.push(plugins.length === 0 ? "plugins: (defaults)" : `plugins: ${plugins.join(", ")}`); + return lines; +} + +/** Read the current settings of a session's family root (undefined when unborn). */ +export async function readFamilySettingsViaClient(input: { + readonly client: SessionSettingsClient; + readonly sessionID: string; +}): Promise { + const root = await resolveFamilyRootViaClient(input.client, input.sessionID); + const read = readSettingsFromMetadata(root.metadata); + if (read === undefined) return undefined; + if ("error" in read) { + throw new Error(`Family settings for ${root.id} are invalid: ${read.error}`); + } + return read.settings; +} + +/** Discover the running native service and build an authenticated client. */ +export async function acquireSettingsClient(): Promise { + const endpoint = await Service.discover({ version: isSupportedServiceVersion }); + if (endpoint === undefined) { + throw new Error( + "No running OpenCode service was discovered. Start the host (`opencode` or `vvoc launch`) and retry.", + ); + } + const headers = Service.headers(endpoint); + return OpenCode.make({ baseUrl: endpoint.url, headers }); +} + +const sessionArg = { + type: "string" as const, + required: true, + description: "Session id of any member of the target family.", +}; + +const presetArg = { + type: "string" as const, + description: "Apply a configured preset's roles and profile to the family.", +}; + +const profileArg = { + type: "string" as const, + description: "Set the orchestration profile (e.g. single-session, orchestrated).", +}; + +const roleArg = { + type: "string" as const, + description: "Set one role: --role =, or remove one: --role unset:.", +}; + +const pluginArg = { + type: "string" as const, + description: "Toggle one vvoc-managed plugin: --plugin =on|off.", +}; + +const pullDefaultsArg = { + type: "boolean" as const, + description: "Replace the family settings with the current effective defaults.", +}; + +const configDirArg = { + type: "string" as const, + description: "Override the global config home.", +}; + +const sessionSet = defineCommand({ + meta: { + name: "set", + description: "Change the session-owned settings of one session family.", + }, + args: { + session: sessionArg, + preset: presetArg, + profile: profileArg, + role: roleArg, + plugin: pluginArg, + "pull-defaults": pullDefaultsArg, + "config-dir": configDirArg, + }, + async run({ args }) { + const actions: SessionSettingsAction[] = []; + if (typeof args.preset === "string" && args.preset.trim().length > 0) { + actions.push({ kind: "preset", name: args.preset }); + } + if (typeof args.profile === "string" && args.profile.trim().length > 0) { + actions.push({ kind: "profile", profile: args.profile }); + } + if (typeof args.role === "string" && args.role.trim().length > 0) { + const raw = args.role.trim(); + if (raw.startsWith("unset:")) { + actions.push({ kind: "role-unset", roleId: raw.slice("unset:".length) }); + } else { + const separator = raw.indexOf("="); + if (separator <= 0) { + throw new Error("--role expects = or unset:"); + } + actions.push({ + kind: "role-set", + roleId: raw.slice(0, separator), + model: raw.slice(separator + 1), + }); + } + } + if (typeof args.plugin === "string" && args.plugin.trim().length > 0) { + const separator = args.plugin.indexOf("="); + if (separator <= 0) { + throw new Error("--plugin expects =on|off"); + } + const state = args.plugin + .slice(separator + 1) + .trim() + .toLowerCase(); + if (state !== "on" && state !== "off") { + throw new Error("--plugin expects =on|off"); + } + actions.push({ + kind: "plugin", + plugin: args.plugin.slice(0, separator), + enabled: state === "on", + }); + } + if (args["pull-defaults"] === true) { + actions.push({ kind: "pull-defaults" }); + } + if (actions.length === 0) { + throw new Error( + "nothing to set; pass --preset, --profile, --role, --plugin, or --pull-defaults", + ); + } + if (actions.length > 1) { + throw new Error("pass exactly one settings action per invocation"); + } + const sessionID = + typeof args.session === "string" && args.session.trim().length > 0 + ? args.session.trim() + : undefined; + if (sessionID === undefined) { + throw new Error("--session is required"); + } + + const client = await acquireSettingsClient(); + const result = await applySessionSettingsAction({ + client, + sessionID, + action: actions[0]!, + configDir: typeof args["config-dir"] === "string" ? args["config-dir"] : undefined, + }); + console.log(`Family ${result.rootID} settings updated:`); + for (const line of describeFamilySettings(result.settings)) { + console.log(` ${line}`); + } + }, +}); + +export default defineCommand({ + meta: { + name: "session", + description: "Inspect and change session-owned family settings.", + }, + subCommands: { + set: sessionSet, + }, +}); diff --git a/src/lib/rebind-request.ts b/src/lib/rebind-request.ts deleted file mode 100644 index f3826dca..00000000 --- a/src/lib/rebind-request.ts +++ /dev/null @@ -1,168 +0,0 @@ -// FILE: src/lib/rebind-request.ts -// VERSION: 1.0.0 -// START_MODULE_CONTRACT -// PURPOSE: Persist and read one-shot, project-directory-scoped rebind requests written by the CLI and consumed lazily by the running snapshot service. -// SCOPE: Marker naming, atomic write, best-effort listing with TTL pruning, removal, strict decoding, and matching by project directory plus optional session. No config mutation, no credential values. -// DEPENDS: [node:fs/promises, node:path, src/lib/vvoc-paths.ts] -// LINKS: [M-CLI-PRESET, M-CLI-PLUGIN-TOGGLE, M-CLI-ROLE, M-NATIVE-RUNTIME] -// ROLE: RUNTIME -// MAP_MODE: EXPORTS -// END_MODULE_CONTRACT -// -// START_MODULE_MAP -// REBIND_REQUEST_DIR_NAME - Directory name holding pending rebind request markers. -// REBIND_REQUEST_TTL_MS - Age after which an unapplied marker is pruned and ignored. -// RebindRequest - One project-directory-scoped rebind request marker. -// writeRebindRequest - Atomically write a rebind request marker and return it. -// requestRebind - Write a marker only when the force flag is set. -// listRebindRequests - List live markers, pruning malformed or expired ones. -// removeRebindRequest - Remove one marker by id. -// rebindRequestMatches - True when a marker targets the given directory and optional session. -// END_MODULE_MAP -// -// START_CHANGE_SUMMARY -// LAST_CHANGE: [C-SNAPSHOT-ANCHORING-REDESIGN T-006 - Added the project-directory-scoped rebind request marker shared by the CLI force flags and the lazy runtime consumer.] -// END_CHANGE_SUMMARY - -import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises"; -import { randomBytes } from "node:crypto"; -import { join, resolve } from "node:path"; -import { getGlobalVvocDataDir } from "./vvoc-paths.js"; - -/** Directory name (inside the vvoc data dir) holding pending rebind request markers. */ -export const REBIND_REQUEST_DIR_NAME = "rebind"; - -/** Age after which an unapplied marker is pruned and ignored, so stale requests cannot surprise a later session. */ -export const REBIND_REQUEST_TTL_MS = 24 * 60 * 60 * 1000; - -/** One project-directory-scoped rebind request written by the CLI and consumed lazily by the runtime. */ -export interface RebindRequest { - readonly id: string; - readonly requestedAt: number; - readonly directory: string; - readonly sessionId?: string; -} - -function requestsDir(dataDir?: string): string { - return join(dataDir ?? getGlobalVvocDataDir(), REBIND_REQUEST_DIR_NAME); -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function decodeRebindRequest(text: string): RebindRequest | undefined { - let value: unknown; - try { - value = JSON.parse(text); - } catch { - return undefined; - } - if (!isRecord(value)) return undefined; - if ( - typeof value.id !== "string" || - typeof value.requestedAt !== "number" || - typeof value.directory !== "string" - ) { - return undefined; - } - if (value.sessionId !== undefined && typeof value.sessionId !== "string") return undefined; - return { - id: value.id, - requestedAt: value.requestedAt, - directory: value.directory, - ...(value.sessionId === undefined ? {} : { sessionId: value.sessionId }), - }; -} - -/** Atomically write a project-directory-scoped rebind request marker. */ -export async function writeRebindRequest(input: { - readonly directory: string; - readonly sessionId?: string | undefined; - readonly dataDir?: string | undefined; - readonly now?: number | undefined; -}): Promise { - const requestedAt = input.now ?? Date.now(); - const id = `${requestedAt}-${randomBytes(4).toString("hex")}`; - const sessionId = input.sessionId?.trim(); - const request: RebindRequest = { - id, - requestedAt, - directory: resolve(input.directory), - ...(sessionId === undefined || sessionId === "" ? {} : { sessionId }), - }; - const dir = requestsDir(input.dataDir); - await mkdir(dir, { recursive: true }); - const path = join(dir, `${id}.json`); - const temporary = `${path}.${randomBytes(4).toString("hex")}.tmp`; - await writeFile(temporary, JSON.stringify(request), { mode: 0o600 }); - await rename(temporary, path); - return request; -} - -/** Write a marker only when `force` is set; return it, or undefined when no rebind was requested. */ -export async function requestRebind(input: { - readonly force: boolean; - readonly directory: string; - readonly sessionId?: string | undefined; - readonly dataDir?: string | undefined; - readonly now?: number | undefined; -}): Promise { - if (!input.force) return undefined; - return writeRebindRequest(input); -} - -/** List live markers, pruning malformed and expired files so requests stay one-shot and bounded. */ -export async function listRebindRequests( - options: { - readonly dataDir?: string | undefined; - readonly now?: number | undefined; - readonly ttlMs?: number | undefined; - } = {}, -): Promise { - const dir = requestsDir(options.dataDir); - let names: string[]; - try { - names = await readdir(dir); - } catch { - return []; - } - const now = options.now ?? Date.now(); - const ttl = options.ttlMs ?? REBIND_REQUEST_TTL_MS; - const live: RebindRequest[] = []; - for (const name of names) { - if (!name.endsWith(".json")) continue; - const path = join(dir, name); - let text: string; - try { - text = await readFile(path, "utf8"); - } catch { - continue; - } - const request = decodeRebindRequest(text); - if (request === undefined || now - request.requestedAt > ttl) { - await rm(path, { force: true }).catch(() => undefined); - continue; - } - live.push(request); - } - return live; -} - -/** Remove one marker by id; used when a request is explicitly consumed. */ -export async function removeRebindRequest( - id: string, - options: { readonly dataDir?: string | undefined } = {}, -): Promise { - await rm(join(requestsDir(options.dataDir), `${id}.json`), { force: true }); -} - -/** True when a marker targets the given project directory and, when set, the given session. */ -export function rebindRequestMatches( - request: RebindRequest, - input: { readonly directory: string; readonly sessionId?: string | undefined }, -): boolean { - if (resolve(input.directory) !== resolve(request.directory)) return false; - if (request.sessionId !== undefined && request.sessionId !== input.sessionId) return false; - return true; -} diff --git a/src/plugins/analytics/index.test.ts b/src/plugins/analytics/index.test.ts index 42ee6570..7f917f14 100644 --- a/src/plugins/analytics/index.test.ts +++ b/src/plugins/analytics/index.test.ts @@ -94,7 +94,7 @@ async function makeHarness( const capture = { familyId: "fam-1", vvoc: config }; let configForCalls = 0; const fakeRuntime = { - snapshots: { + settings: { configFor: async (sessionID: string) => { configForCalls += 1; if (options.configForThrows !== undefined && configForCalls <= options.configForThrows) { @@ -103,7 +103,6 @@ async function makeHarness( if (options.resolve !== undefined) return options.resolve(sessionID); return policy === "unknown" ? undefined : capture; }, - accept: async () => ({ status: "unbound" }), }, release: async () => { released = true; diff --git a/src/plugins/analytics/index.ts b/src/plugins/analytics/index.ts index 95503d13..08e3019d 100644 --- a/src/plugins/analytics/index.ts +++ b/src/plugins/analytics/index.ts @@ -28,7 +28,7 @@ import { type NativeSnapshotContext, type NativeSnapshotRuntime, } from "../../runtime/context.js"; -import type { FamilyCapture } from "../../runtime/types.js"; +import type { FamilySettingsView } from "../../runtime/session-settings.js"; import { appendAnalyticsRecord } from "../../lib/analytics/store.js"; import type { AnalyticsRecord, UsageRecord, UsageTokens } from "../../lib/analytics/types.js"; @@ -138,24 +138,14 @@ export function createAnalyticsPlugin( const resolveEnabled = async (sessionID: string): Promise => { const cached = sessionPolicy.get(sessionID); if (cached !== undefined) return cached; - const read = async (): Promise => { - try { - return await runtime.snapshots.configFor(sessionID); - } catch { - return undefined; - } - }; - let capture = await read(); - if (capture === undefined) { - try { - await runtime.snapshots.accept({ sessionID }); - } catch { - // fall through to the second read; absence is unknown policy below - } - capture = await read(); + let view: FamilySettingsView | undefined; + try { + view = await runtime.settings.configFor(sessionID); + } catch { + return undefined; } - if (capture === undefined) return undefined; - const enabled = isVvocPluginEnabled(capture.vvoc, "analytics"); + if (view === undefined) return undefined; + const enabled = isVvocPluginEnabled(view.vvoc, "analytics"); sessionPolicy.set(sessionID, enabled); return enabled; }; diff --git a/src/plugins/guardian.integration.test.ts b/src/plugins/guardian.integration.test.ts index 0939b843..dee41946 100644 --- a/src/plugins/guardian.integration.test.ts +++ b/src/plugins/guardian.integration.test.ts @@ -385,9 +385,8 @@ describe("Guardian native permission evaluate", () => { let released = false; let hookName: string | undefined; const fakeRuntime = { - snapshots: { + settings: { configFor: async () => undefined, - accept: async () => ({ status: "unbound" }), }, client: async () => ({ session: { context: async () => [] } }), auxiliary: { generate: async () => ({ text: "{}" }) }, @@ -458,9 +457,8 @@ describe("Guardian native permission evaluate", () => { tempDirs.push(directory); let evaluate: ((event: GuardianPermissionEvaluation) => Promise) | undefined; const fakeRuntime = { - snapshots: { + settings: { configFor: async () => ({ familyId: "fam-provider", vvoc }), - accept: async () => ({ status: "bound" }), }, client: async () => ({ session: { context: async () => [] } }), auxiliary: { diff --git a/src/plugins/guardian/index.ts b/src/plugins/guardian/index.ts index 85018ccd..792e2abc 100644 --- a/src/plugins/guardian/index.ts +++ b/src/plugins/guardian/index.ts @@ -32,7 +32,7 @@ import { type NativeSnapshotContext, type NativeSnapshotRuntime, } from "../../runtime/context.js"; -import type { FamilyCapture } from "../../runtime/types.js"; +import type { FamilySettingsView } from "../../runtime/session-settings.js"; import { type VvocConfigSnapshot } from "../../lib/config-layers.js"; import { loadManagedAgentPromptText } from "../../lib/managed-agents.js"; import { @@ -788,33 +788,21 @@ export function createGuardianPlugin(options: GuardianPluginOptions = {}): Plugi const deps: GuardianReviewDependencies = { async policyFor(sessionID) { - let capture: FamilyCapture | undefined; + let view: FamilySettingsView | undefined; try { - capture = await runtime.snapshots.configFor(sessionID); + view = await runtime.settings.configFor(sessionID); } catch { - capture = undefined; + view = undefined; } - if (capture === undefined) { - try { - await runtime.snapshots.accept({ sessionID }); - } catch { - // fall through to the second read - } - try { - capture = await runtime.snapshots.configFor(sessionID); - } catch { - capture = undefined; - } - } - if (capture === undefined) return undefined; - if (!isVvocPluginEnabled(capture.vvoc, "guardian")) return undefined; + if (view === undefined) return undefined; + if (!isVvocPluginEnabled(view.vvoc, "guardian")) return undefined; const config = resolveGuardianRuntimeConfig({ - config: capture.vvoc, + config: view.vvoc, source: { kind: "project" }, warnings: [], loadedAt: new Date().toISOString(), }); - return { familyId: capture.familyId, config, prompt: prompt ?? "" }; + return { familyId: view.familyId, config, prompt: prompt ?? "" }; }, history: async (sessionID) => loadHistory(await getClient(), sessionID), infer: (input) => inferWithTimeout(runtime, input), diff --git a/src/plugins/hashline-edit.integration.test.ts b/src/plugins/hashline-edit.integration.test.ts index 9d3490bf..99bd1094 100644 --- a/src/plugins/hashline-edit.integration.test.ts +++ b/src/plugins/hashline-edit.integration.test.ts @@ -924,9 +924,8 @@ describe("HashlineEditPlugin cold-start native definitions", () => { const vvoc = createDefaultVvocConfig(); vvoc.plugins = { ...vvoc.plugins, "hashline-edit": settings as never }; const fakeRuntime = { - snapshots: { + settings: { configFor: async () => ({ familyId: "fam-1", vvoc }), - accept: async () => ({ status: "unbound" }), }, permissions: { guard: async (_input: unknown, effect: () => Promise | unknown) => effect(), diff --git a/src/plugins/hashline-edit/index.ts b/src/plugins/hashline-edit/index.ts index 0ec9377a..bd2fa703 100644 --- a/src/plugins/hashline-edit/index.ts +++ b/src/plugins/hashline-edit/index.ts @@ -41,7 +41,7 @@ import { type NativeSnapshotContext, type NativeSnapshotRuntime, } from "../../runtime/context.js"; -import type { FamilyCapture } from "../../runtime/types.js"; +import type { FamilySettingsView } from "../../runtime/session-settings.js"; import { ContractInputError, formatContractIssues } from "../../lib/agent-tool-contract.js"; import { findFirstChangedLine, summarizeEditDiff } from "./diff-summary.js"; import { applyHashlineEditsWithReport } from "./edit-operations.js"; @@ -1299,32 +1299,20 @@ export function createHashlineEditHandlers( // END_BLOCK_HANDLERS // START_BLOCK_PLUGIN_ENTRY -/** Resolve captured per-family hashline-edit settings; unbound/disabled fails closed. */ +/** Resolve per-family hashline-edit settings from the projected family config; unresolved fails closed. */ async function resolveSettings( runtime: NativeSnapshotRuntime, sessionID: string, ): Promise { - let capture: FamilyCapture | undefined; + let view: FamilySettingsView | undefined; try { - capture = await runtime.snapshots.configFor(sessionID); + view = await runtime.settings.configFor(sessionID); } catch { - capture = undefined; + view = undefined; } - if (capture === undefined) { - try { - await runtime.snapshots.accept({ sessionID }); - } catch { - // fall through to the second read - } - try { - capture = await runtime.snapshots.configFor(sessionID); - } catch { - capture = undefined; - } - } - if (capture === undefined) return undefined; + if (view === undefined) return undefined; try { - return parseHashlineEditPluginEntry(capture.vvoc.plugins?.["hashline-edit"]); + return parseHashlineEditPluginEntry(view.vvoc.plugins?.["hashline-edit"]); } catch { return undefined; } diff --git a/src/plugins/model-roles.integration.test.ts b/src/plugins/model-roles.integration.test.ts index 66d7be21..a7697e14 100644 --- a/src/plugins/model-roles.integration.test.ts +++ b/src/plugins/model-roles.integration.test.ts @@ -1,43 +1,38 @@ // FILE: src/plugins/model-roles.integration.test.ts -// VERSION: 2.0.0 +// VERSION: 3.0.0 // START_MODULE_CONTRACT -// PURPOSE: Verify the native ModelRolesPlugin and the production acquireNativeSnapshotRuntime wrapper against native-shaped transforms, hooks, lineage, provenance, config updates and auxiliary fork work. -// SCOPE: Native Plugin.define export shape, disabled role override, shared-Context acquisition, bind-on-first-work and qualified-variant materialization, all-kind unbound refusal, agent-aware role selection, initial explicit/implicit provenance, post-bind explicit choices, config.updated reconfiguration, per-event event-pump error containment and recovery (invalid and transient config reads followed by valid lifecycle events), created auxiliary title children, and an optional isolated real OpenCode 2.0.18 host smoke loading the actual built plugin against a loopback provider (skipped unless VVOC_E2E_V2_HOST is set). -// DEPENDS: [bun:test, node:fs/promises, node:net, node:os, node:path, @opencode/schema/agent, @opencode/schema/model, @opencode/schema/provider, src/lib/vvoc-config.ts, src/plugins/model-roles/index.ts, src/runtime/context.ts, src/runtime/types.ts] +// PURPOSE: Verify the native ModelRolesPlugin delegation and the shared runtime's session-owned family settings: lazy root birth, lineage child resolution, restart parity, explicit metadata changes, one-shot creation reconciliation, config watching, auxiliary title children, and the one-shot legacy migration. +// SCOPE: Deterministic fake-native-host assertions with an isolated data home; no real host, network, or V1 client. +// DEPENDS: [bun:test, node:fs/promises, node:crypto, @opencode/schema/agent, @opencode/schema/model, @opencode/schema/provider, src/lib/vvoc-config.js, src/plugins/model-roles/index.js, src/runtime/context.js, src/runtime/session-settings.js, src/runtime/types.js] // LINKS: [M-PLUGIN-MODEL-ROLES, M-NATIVE-RUNTIME, V-M-PLUGIN-MODEL-ROLES] // ROLE: TEST // MAP_MODE: LOCALS // END_MODULE_CONTRACT // // START_MODULE_MAP -// AgentInfo - Native agent info type alias used by the fake context. -// ModelInfo - Native model info type alias used by the fake context. -// EventQueue - Drainable async event stream double. -// FakeNativeContext - Native-shaped context double with editors, sessions, hooks and an event queue. -// FakeRuntimeHost - In-memory discovery/authentication/client double adding session fork/import/log. -// tempDirs - Tracks temporary project/data roots for cleanup. -// previousDataHome - Saved XDG_DATA_HOME for restoration. -// previousVvocConfig - Saved VVOC_CONFIG for restoration. -// createProject - Create an isolated project with a canonical vvoc config file. -// writeVvoc - Write a canonical vvoc config document. -// writeOpenCodeConfig - Write an OpenCode config document. -// isolateDataHome - Point the runtime data dir at an isolated temporary root. -// makeBoundSession - Register a bound session view on the fake context. -// promptWork - Invoke the first-work prompt gateway without obsolete acceptance replay. +// EventQueue - Deterministic native event queue double. +// FakeNativeContext - In-memory native Plugin.Context double with persistent session metadata. +// FakeRuntimeHost - Discovery/auth/client double bound to one fake context. +// AgentInfo - Native agent info type alias used by the fakes. +// ModelInfo - Native model info type alias used by the fakes. +// acquireRuntime - Acquire a settings-based runtime over the fake host with role overriding enabled unless stated. +// createProject - Create an isolated fixture project with the given role map. +// isolateDataHome - Point XDG_DATA_HOME at a fresh temporary directory. +// makeSession - Register one session view in the fake context. +// previousDataHome - Saved XDG_DATA_HOME restored after the suite. +// previousVvocConfig - Saved VVOC_CONFIG restored after the suite. +// tempDirs - Temporary directories removed after the suite. // waitFor - Poll a predicate until it holds or the bounded deadline elapses. -// REAL_HOST - Optional pinned real-host binary path. -// smokeDescribe - Describe-or-skip wrapper for the real-host smoke. -// getFreePort - Reserve and release a loopback TCP port for the host smoke. +// writeVvoc - Write the fixture project's vvoc.json with the given roles. // END_MODULE_MAP // // START_CHANGE_SUMMARY -// LAST_CHANGE: [Direct fix - Reserved the host-smoke provider and host ports ephemerally via getFreePort instead of fixed loopback ports, so an occupied port can no longer fail the smoke as an ordinary test failure.] -// PREVIOUS: [Direct fix follow-up - Added the pump-missed user-choice regression: an external session-model change the event pump never saw must close the first-work window, so its bare-base dispatch refuses as drift.] +// LAST_CHANGE: [C-SESSION-OWNED-SETTINGS T-003 - Rewrote the suite for session-owned settings: birth, resolution, restart parity, reconciliation, and migration replace the binding/guard coverage.] // END_CHANGE_SUMMARY -import { afterAll, afterEach, describe, expect, test } from "bun:test"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { createServer } from "node:net"; +import { describe, expect, test, afterEach } from "bun:test"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Agent } from "@opencode/schema/agent"; @@ -49,10 +44,14 @@ import { ModelRolesPlugin, registerModelRoles } from "./model-roles/index.js"; import { acquireNativeSnapshotRuntime, type NativeForkClient, + type NativeHookEvents, type NativeRegistration, type NativeSnapshotContext, - type NativeSnapshotHookEvents, } from "../runtime/context.js"; +import { + SESSION_SETTINGS_METADATA_KEY, + readSettingsFromMetadata, +} from "../runtime/session-settings.js"; import { type AgentEditorLike, type ModelEditorLike, @@ -97,16 +96,12 @@ async function writeVvoc(dir: string, roles: Record): Promise { - await mkdir(join(dir, ".opencode"), { recursive: true }); - await writeFile(join(dir, ".opencode", "opencode.json"), JSON.stringify(value, null, 2), "utf8"); -} - -async function isolateDataHome(): Promise { +async function isolateDataHome(): Promise { const dir = await mkdtemp(join(tmpdir(), "vvoc-mr-data-")); tempDirs.push(dir); process.env.XDG_DATA_HOME = dir; delete process.env.VVOC_CONFIG; + return dir; } type AgentInfo = DeepMutable; @@ -165,7 +160,11 @@ class FakeNativeContext implements NativeSnapshotContext { readonly sessions = new Map(); readonly sessionModels = new Map(); readonly switchCalls: Array<{ sessionID: string; model: ModelSelection }> = []; - readonly updateCalls: Array<{ sessionID: string; title: string }> = []; + readonly updateCalls: Array<{ + sessionID: string; + title?: string; + metadata?: Record; + }> = []; readonly generateCalls: Array<{ sessionID: string; prompt: string }> = []; readonly importCalls: Array<{ id: string; @@ -184,7 +183,7 @@ class FakeNativeContext implements NativeSnapshotContext { agentReloads = 0; private defaultModel: { providerID: string; modelID: string } | undefined; private readonly hooks = new Map< - keyof NativeSnapshotHookEvents, + keyof NativeHookEvents, (event: never) => void | Promise >(); private readonly agentTransforms: Array<(editor: AgentEditorLike) => void> = []; @@ -305,7 +304,7 @@ class FakeNativeContext implements NativeSnapshotContext { this.modelReloads += 1; for (const callback of this.modelTransforms) callback(this.modelEditor()); }, - // Final native model collection readback used by the production wrapper. + // Final native model collection readback. list: async () => ({ data: [...this.models.values()] }), }; @@ -350,16 +349,29 @@ class FakeNativeContext implements NativeSnapshotContext { this.switchCalls.push({ sessionID, model: selection }); this.sessionModels.set(sessionID, selection); }, - update: async ({ sessionID, title }: { sessionID: string; title: string }) => { - this.updateCalls.push({ sessionID, title }); + // REPLACE semantics: the whole metadata map replaces the previous one. + update: async (input: { + sessionID: string; + title?: string; + metadata?: Record; + }) => { + this.updateCalls.push({ + sessionID: input.sessionID, + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.metadata === undefined ? {} : { metadata: input.metadata }), + }); + const view = this.sessions.get(input.sessionID); + if (view !== undefined && input.metadata !== undefined) { + this.sessions.set(input.sessionID, { ...view, metadata: input.metadata }); + } }, generate: async ({ sessionID, prompt }: { sessionID: string; prompt: string }) => { this.generateCalls.push({ sessionID, prompt }); return { text: `generated:${sessionID}` }; }, - hook: async ( + hook: async ( name: Name, - callback: (event: NativeSnapshotHookEvents[Name]) => void | Promise, + callback: (event: NativeHookEvents[Name]) => void | Promise, ): Promise => { this.hooks.set(name, callback as (event: never) => void | Promise); return this.registration(); @@ -370,20 +382,17 @@ class FakeNativeContext implements NativeSnapshotContext { subscribe: () => this.events.drain(), }; - async invoke( + async invoke( name: Name, - event: NativeSnapshotHookEvents[Name], + event: NativeHookEvents[Name], ): Promise { const callback = this.hooks.get(name) as - | ((event: NativeSnapshotHookEvents[Name]) => void | Promise) + | ((event: NativeHookEvents[Name]) => void | Promise) | undefined; await callback?.(event); } - /** - * Production-compatible seam: a real Plugin.Context satisfies the narrow - * snapshot context, so this fake is the same shape the wrapper receives. - */ + /** Production-compatible seam: a real Plugin.Context satisfies the narrow context. */ private registration(): NativeRegistration { return { dispose: async () => undefined }; } @@ -447,7 +456,7 @@ class FakeRuntimeHost { } // END_BLOCK_RUNTIME_HOST -function makeBoundSession( +function makeSession( context: FakeNativeContext, id: string, view: Partial = {}, @@ -455,11 +464,6 @@ function makeBoundSession( context.sessions.set(id, { id, locationDirectory: context.location.directory, ...view }); } -/** Invoke the bind-on-first-work prompt gateway without acceptance replay. */ -async function promptWork(context: FakeNativeContext, sessionID: string): Promise { - await context.invoke("prompt", { sessionID }); -} - /** Poll a predicate until it holds or the bounded deadline elapses. */ async function waitFor(predicate: () => boolean, timeoutMs = 1000): Promise { const deadline = Date.now() + timeoutMs; @@ -469,6 +473,16 @@ async function waitFor(predicate: () => boolean, timeoutMs = 1000): Promise { test("exposes a native Plugin.define object", () => { expect(typeof ModelRolesPlugin).toBe("object"); @@ -476,7 +490,7 @@ describe("ModelRolesPlugin native delegation", () => { expect(ModelRolesPlugin.id).toBe("vvoc.model-roles"); }); - test("shares one snapshot runtime for the same Context and exposes the same service to later plugins", async () => { + test("shares one runtime and settings service for the same Context", async () => { await isolateDataHome(); const project = await createProject({ default: "prov/m1" }); const context = new FakeNativeContext(project); @@ -485,21 +499,20 @@ describe("ModelRolesPlugin native delegation", () => { const first = await acquireNativeSnapshotRuntime(context, { runtimeDeps }); const second = await acquireNativeSnapshotRuntime(context, { runtimeDeps }); - expect(second.snapshots).toBe(first.snapshots); + expect(second.settings).toBe(first.settings); await first.release(); await second.release(); }); - test("disabled role override still captures policy while leaving agent models untouched", async () => { + test("disabled role override leaves agent models untouched", async () => { await isolateDataHome(); const project = await createProject({ default: "prov/m1", smart: "prov/m2" }); const context = new FakeNativeContext(project); - const host = new FakeRuntimeHost(context); context.addAgent("vv-controller"); const registration = await registerModelRoles(context, { enabled: false, - runtimeDeps: host.createDeps(), + runtimeDeps: new FakeRuntimeHost(context).createDeps(), }); expect(context.agents.get("vv-controller")?.model).toBeUndefined(); await registration.dispose(); @@ -509,13 +522,12 @@ describe("ModelRolesPlugin native delegation", () => { await isolateDataHome(); const project = await createProject({ default: "prov/m1", smart: "prov/m2" }); const context = new FakeNativeContext(project); - const host = new FakeRuntimeHost(context); context.addAgent("vv-controller"); context.addAgent("plan", { providerID: "user", modelID: "literal" }); const registration = await registerModelRoles(context, { enabled: true, - runtimeDeps: host.createDeps(), + runtimeDeps: new FakeRuntimeHost(context).createDeps(), }); expect(context.agents.get("vv-controller")?.model).toEqual({ id: Model.ID.make("m2"), @@ -529,1476 +541,372 @@ describe("ModelRolesPlugin native delegation", () => { }); }); -describe("native snapshot runtime admission", () => { - test("binds on the first prompt and materializes the qualified variant on its real model identity", async () => { +describe("session-owned family settings", () => { + test("the first context hook births root settings and later hooks do not rewrite them", async () => { await isolateDataHome(); - const project = await createProject({ - default: "prov/m1", - smart: "xiaomi/mimo-v2.6-flash#thinking", - }); + const project = await createProject({ smart: "prov/m2" }); const context = new FakeNativeContext(project); - const host = new FakeRuntimeHost(context); - context.addModel("prov", "m1", { temperature: 0 }); - context.addModel("xiaomi", "mimo-v2.6-flash"); - context.setDefault("prov", "m1"); - makeBoundSession(context, "root", { model: { providerID: "prov", modelID: "m1" } }); - - const registration = await registerModelRoles(context, { - enabled: true, - runtimeDeps: host.createDeps(), - }); - await promptWork(context, "root"); - - expect(context.switchCalls).toHaveLength(1); - expect(context.switchCalls[0]?.model).toEqual({ - providerID: "prov", - modelID: "m1", - variant: expect.stringMatching(/^[0-9a-f]+\.m1$/), - }); - const resolved = context.switchCalls[0]?.model; - await context.invoke("model.request", { - sessionID: "root", - model: { - id: resolved?.modelID ?? "m1", - providerID: resolved?.providerID ?? "prov", - ...(resolved?.variant === undefined ? {} : { variant: resolved.variant }), - }, - kind: "primary", - }); - - const mimo = context.models.get("xiaomi/mimo-v2.6-flash"); - expect(mimo?.body).toBeUndefined(); - // The managed MiMo thinking variant exists on the real model after bind + refresh. - expect(context.modelReloads).toBeGreaterThan(1); + makeSession(context, "ses_root"); + const runtime = await acquireRuntime(context, { enabled: false }); - // A rehydrated runtime must not re-capture its own materialized variants as source overlays. - const runtime = await acquireNativeSnapshotRuntime(context, { - runtimeDeps: host.createDeps(), - }); - const captures = await runtime.snapshots.captures(); - const generatedIds = new Set( - captures.flatMap((capture) => capture.variants.map((variant) => variant.id)), - ); - const accumulated = captures.flatMap((capture) => - capture.modelSettings.filter( - (settings) => settings.variant !== undefined && generatedIds.has(settings.variant), - ), + await context.invoke("context", { sessionID: "ses_root" }); + await waitFor(() => context.updateCalls.length > 0); + const stored = readSettingsFromMetadata( + context.sessions.get("ses_root")?.metadata as Record, ); - expect(accumulated).toEqual([]); + // The birth record carries the FULL resolved role map (seeded defaults + // included), so the family is immune to later config edits. + expect(stored && "settings" in stored ? stored.settings.roles : undefined).toEqual({ + default: "openai/gpt-5.6-terra#high", + fast: "openai/gpt-6-luna#low", + reflector: "openai/gpt-6-luna#low", + reviewer: "openai/gpt-5.6-sol#xhigh", + smart: "prov/m2", + }); + + const writesAfterFirst = context.updateCalls.length; + await context.invoke("context", { sessionID: "ses_root" }); + expect(context.updateCalls.length).toBe(writesAfterFirst); await runtime.release(); - await registration.dispose(); }); - test("a location-only first turn survives its pre-binding bare-base dispatch", async () => { + test("birth preserves foreign metadata keys (read-modify-write)", async () => { await isolateDataHome(); - const project = await createProject({ default: "prov/m1#fast" }); + const project = await createProject({ smart: "prov/m2" }); const context = new FakeNativeContext(project); - const host = new FakeRuntimeHost(context); - const model = context.addModel("prov", "m1"); - model.variants.push({ id: Model.VariantID.make("fast"), settings: { temperature: 0.2 } }); - context.setDefault("prov", "m1"); - // The reported shape: no agent and no model on the session, so the host can - // resolve the first dispatch against the unqualified role base before the - // prompt hook's binding switch lands. - makeBoundSession(context, "root"); - - const registration = await registerModelRoles(context, { - enabled: true, - runtimeDeps: host.createDeps(), - }); - await promptWork(context, "root"); - const bound = context.switchCalls[0]?.model; - expect(bound?.providerID).toBe("prov"); - expect(bound?.modelID).toBe("m1"); - expect(bound?.variant).toMatch(/\.m1\.fast$/); - - // The pre-binding resolution: the bare role base without the family variant. - await context.invoke("model.request", { - sessionID: "root", - model: { id: "m1", providerID: "prov" }, - kind: "primary", - }); - - // After the switch, resolutions carry the family-qualified variant. - await context.invoke("model.request", { - sessionID: "root", - model: { - id: "m1", - providerID: "prov", - ...(bound?.variant === undefined ? {} : { variant: bound.variant }), - }, - kind: "primary", - }); - - // The tolerance is one-shot: a later bare-base dispatch is genuine drift. - await expect( - context.invoke("model.request", { - sessionID: "root", - model: { id: "m1", providerID: "prov" }, - kind: "primary", - }), - ).rejects.toThrow(/does not match/); - await registration.dispose(); - }); - - test("a pump-missed user choice closes the first-work window before its dispatch", async () => { - await isolateDataHome(); - const project = await createProject({ default: "prov/m1#fast" }); - const context = new FakeNativeContext(project); - const host = new FakeRuntimeHost(context); - const model = context.addModel("prov", "m1"); - model.variants.push({ id: Model.VariantID.make("fast"), settings: { temperature: 0.2 } }); - context.setDefault("prov", "m1"); - makeBoundSession(context, "root"); - - const registration = await registerModelRoles(context, { - enabled: true, - runtimeDeps: host.createDeps(), - }); - // First work binds and switches, arming the one-shot window; no guarded - // dispatch has happened yet, so the window is still open here. - await promptWork(context, "root"); - expect(context.switchCalls).toHaveLength(1); + makeSession(context, "ses_root", { metadata: { user: { note: "keep me" }, count: 3 } }); + const runtime = await acquireRuntime(context, { enabled: false }); - // A user choice the event pump missed: the session model changed outside - // our own switch with no model-selected event. bindFor must record it as - // an explicit selection and end the first-work tolerance with it. - context.sessionModels.set("root", { providerID: "prov", modelID: "m1" }); - await promptWork(context, "root"); - // An explicit choice is never re-switched by role overriding. - expect(context.switchCalls).toHaveLength(1); - - // The bare-base dispatch is genuine drift now: an explicit choice resolves - // qualified after the first-work switch, so the closed window must refuse. - await expect( - context.invoke("model.request", { - sessionID: "root", - model: { id: "m1", providerID: "prov" }, - kind: "primary", - }), - ).rejects.toThrow(/does not match/); - await registration.dispose(); - }); - - test("refuses unbound dispatch for every request kind", async () => { - await isolateDataHome(); - const project = await createProject({ default: "prov/m1" }); - const context = new FakeNativeContext(project); - const host = new FakeRuntimeHost(context); - makeBoundSession(context, "unbound"); - - const registration = await registerModelRoles(context, { - enabled: true, - runtimeDeps: host.createDeps(), - }); - for (const kind of ["primary", "title", "compaction", "generate"]) { - await expect( - context.invoke("model.request", { - sessionID: "unbound", - model: { id: "m1", providerID: "prov" }, - kind, - }), - ).rejects.toThrow(/unbound/); - } - await registration.dispose(); - }); - - test("initial selection differing from the default is preserved as explicit", async () => { - await isolateDataHome(); - const project = await createProject({ default: "prov/m1" }); - const context = new FakeNativeContext(project); - const host = new FakeRuntimeHost(context); - context.addModel("prov", "m1"); - context.addModel("prov", "user-pick"); - context.setDefault("prov", "m1"); - makeBoundSession(context, "explicit", { - model: { providerID: "prov", modelID: "user-pick" }, - }); - - const registration = await registerModelRoles(context, { - enabled: true, - runtimeDeps: host.createDeps(), - }); - await promptWork(context, "explicit"); - - expect(context.switchCalls[0]?.model).toEqual({ providerID: "prov", modelID: "user-pick" }); - await registration.dispose(); + await context.invoke("context", { sessionID: "ses_root" }); + await waitFor(() => context.updateCalls.length > 0); + const metadata = context.sessions.get("ses_root")?.metadata as Record; + expect(metadata.user).toEqual({ note: "keep me" }); + expect(metadata.count).toBe(3); + expect(metadata[SESSION_SETTINGS_METADATA_KEY]).toBeDefined(); + await runtime.release(); }); - test("a config.updated switch changes future sessions while a bound family stays frozen", async () => { + test("a child resolves through the root's record while a fresh session resolves the changed config", async () => { await isolateDataHome(); - const project = await createProject({ default: "prov/m1" }); + const project = await createProject({ smart: "prov/m-x" }); const context = new FakeNativeContext(project); - const host = new FakeRuntimeHost(context); - context.addModel("prov", "m1"); - context.addModel("prov", "m9"); - context.setDefault("prov", "m1"); - makeBoundSession(context, "bound", { model: { providerID: "prov", modelID: "m1" } }); - makeBoundSession(context, "fresh", { model: { providerID: "prov", modelID: "m1" } }); + makeSession(context, "ses_root"); + makeSession(context, "ses_child", { parentID: "ses_root" }); + const runtime = await acquireRuntime(context, { enabled: false }); - const registration = await registerModelRoles(context, { - enabled: true, - runtimeDeps: host.createDeps(), - }); - await promptWork(context, "bound"); - await context.invoke("model.request", { - sessionID: "bound", - model: { id: "m1", providerID: "prov" }, - kind: "primary", - }); - expect(context.switchCalls).toHaveLength(1); + await context.invoke("context", { sessionID: "ses_root" }); + await waitFor( + () => + readSettingsFromMetadata( + context.sessions.get("ses_root")?.metadata as Record, + ) !== undefined, + ); - await writeVvoc(project, { default: "prov/m9" }); + // The project config flips to Y; the host emits one config.updated. + await writeVvoc(project, { smart: "prov/m-y" }); context.events.push({ type: "config.updated" }); - await new Promise((resolve) => setTimeout(resolve, 20)); - - // A fresh session created after the preset switch carries the new default, so it is implicit. - context.sessionModels.set("fresh", { providerID: "prov", modelID: "m9" }); - await promptWork(context, "fresh"); - const freshSwitch = context.switchCalls.at(-1); - expect(freshSwitch?.model.providerID).toBe("prov"); - expect(freshSwitch?.model.modelID).toBe("m9"); - - // The bound family stays frozen even after the preset switch. - await promptWork(context, "bound"); - const boundSwitches = context.switchCalls.filter((call) => call.sessionID === "bound"); - expect(boundSwitches).toHaveLength(1); - expect(boundSwitches[0]?.model.modelID).toBe("m1"); - await registration.dispose(); + await waitFor(() => runtime.effectiveConfig().roles.smart === "prov/m-y", 2000); + + // The existing family keeps its birth settings (X) through the root record. + const familyView = await runtime.settings.configFor("ses_child"); + expect(familyView?.source).toBe("metadata"); + expect(familyView?.settings.roles.smart).toBe("prov/m-x"); + expect(familyView?.familyId).toBe("ses_root"); + + // A brand-new session resolves the current effective config (Y). + makeSession(context, "ses_fresh"); + const freshView = await runtime.settings.configFor("ses_fresh"); + expect(freshView?.source).toBe("metadata"); + expect(freshView?.settings.roles.smart).toBe("prov/m-y"); + + // The projected consumer config carries the family roles over the live document. + expect(familyView?.vvoc.roles.smart).toBe("prov/m-x"); + expect(freshView?.vvoc.roles.smart).toBe("prov/m-y"); + await runtime.release(); }); - test("the event pump contains an invalid config event and recovers on a fixed one", async () => { + test("a fresh runtime resolves the same settings after a restart", async () => { await isolateDataHome(); - const project = await createProject({ default: "prov/m1" }); + const project = await createProject({ smart: "prov/m-x" }); const context = new FakeNativeContext(project); - const host = new FakeRuntimeHost(context); - const deps = host.createDeps(); - context.addModel("prov", "m1"); - context.addModel("prov", "m9"); - context.setDefault("prov", "m1"); - makeBoundSession(context, "root", { model: { providerID: "prov", modelID: "m1" } }); - - const runtime = await acquireNativeSnapshotRuntime(context, { runtimeDeps: deps }); - await runtime.setRoleOverride(true); - try { - await promptWork(context, "root"); - await context.invoke("model.request", { - sessionID: "root", - model: { id: "m1", providerID: "prov" }, - kind: "primary", - }); - const switchesAfterBind = context.switchCalls.length; - - // Break the on-disk config so a config.updated read throws. - await writeFile(join(project, ".vvoc", "vvoc.json"), "{ this is not valid json", "utf8"); - context.events.push({ type: "config.updated" }); - await waitFor(() => runtime.lastConfigError() !== undefined); - expect(runtime.lastConfigError()).toBeDefined(); - // The mutable current config keeps the last fully-read value, never a partial read. - expect(runtime.effectiveConfig()).toBeDefined(); - - // A lifecycle event delivered while the config is invalid is still observed. - context.events.push({ - type: "session.model.selected", - data: { sessionID: "root", model: { id: "m9", providerID: "prov" } }, - }); - await new Promise((resolve) => setTimeout(resolve, 15)); - - // Fix the config; the same live pump recovers without a restart. - await writeVvoc(project, { default: "prov/m1" }); - context.events.push({ type: "config.updated" }); - await waitFor(() => runtime.lastConfigError() === undefined); - expect(runtime.lastConfigError()).toBeUndefined(); - - // The intervening model.selected was processed: the explicit m9 survives. - await promptWork(context, "root"); - expect(context.switchCalls.length).toBe(switchesAfterBind); - await expect( - context.invoke("model.request", { - sessionID: "root", - model: { id: "m9", providerID: "prov" }, - kind: "primary", - }), - ).resolves.toBeUndefined(); - } finally { - await runtime.release(); - } - }); + makeSession(context, "ses_root"); + makeSession(context, "ses_child", { parentID: "ses_root" }); - test("the event pump survives a transient refresh failure and processes later events", async () => { - await isolateDataHome(); - const project = await createProject({ default: "prov/m1" }); - const context = new FakeNativeContext(project); - const host = new FakeRuntimeHost(context); - const deps = host.createDeps(); - context.addModel("prov", "m1"); - context.setDefault("prov", "m1"); - makeBoundSession(context, "root", { model: { providerID: "prov", modelID: "m1" } }); - - const runtime = await acquireNativeSnapshotRuntime(context, { runtimeDeps: deps }); - await runtime.setRoleOverride(true); - const modelEditor = context.model as unknown as { reload: () => Promise }; - const originalReload = modelEditor.reload.bind(context.model); - let failNextReload = true; - modelEditor.reload = async () => { - if (failNextReload) { - failNextReload = false; - throw new Error("transient model reload failure"); - } - return originalReload(); - }; - try { - context.events.push({ type: "config.updated" }); - await waitFor(() => runtime.lastConfigError() !== undefined); - expect(runtime.lastConfigError()).toBeDefined(); - - // The subscription is alive: a lifecycle event and a second config event both process. - context.events.push({ - type: "session.created", - data: { sessionID: "root", model: { id: "m1", providerID: "prov" } }, - }); - context.events.push({ type: "config.updated" }); - await waitFor(() => runtime.lastConfigError() === undefined); - expect(runtime.lastConfigError()).toBeUndefined(); - } finally { - await runtime.release(); - } - }); + const first = await acquireRuntime(context, { enabled: false }); + const before = await first.settings.configFor("ses_child"); + await first.release(); - test("a bound family ignores an invalid current config while a new unbound family fails closed", async () => { - await isolateDataHome(); - const project = await createProject({ default: "prov/m1" }); - const context = new FakeNativeContext(project); - const host = new FakeRuntimeHost(context); - const deps = host.createDeps(); - context.addModel("prov", "m1"); - context.setDefault("prov", "m1"); - makeBoundSession(context, "bound", { model: { providerID: "prov", modelID: "m1" } }); - - const runtime = await acquireNativeSnapshotRuntime(context, { runtimeDeps: deps }); - await runtime.setRoleOverride(true); - try { - // Bind family A and record the capture-backed switch. - await promptWork(context, "bound"); - await context.invoke("model.request", { - sessionID: "bound", - model: { id: "m1", providerID: "prov" }, - kind: "primary", - }); - const switchesAfterBind = context.switchCalls.length; - - // Corrupt only the MUTABLE current config; the bound capture stays valid. - await writeFile(join(project, ".vvoc", "vvoc.json"), "{ broken current config", "utf8"); - - // The already-bound workload keeps using its immutable capture: no new - // switch, no failure, and no adoption of the invalid current config. - await promptWork(context, "bound"); - expect(context.switchCalls.length).toBe(switchesAfterBind); - await expect( - context.invoke("model.request", { - sessionID: "bound", - model: { id: "m1", providerID: "prov" }, - kind: "primary", - }), - ).resolves.toBeUndefined(); - - // A brand-new unbound session must fail closed while the config is invalid. - makeBoundSession(context, "newborn", { model: { providerID: "prov", modelID: "m1" } }); - const refused = await context - .invoke("prompt", { sessionID: "newborn", messageID: "msg-newborn" }) - .then(() => undefined) - .catch((error: Error) => error); - expect(refused).toBeDefined(); - - // Fixing the config lets the same pump and the new session recover. - await writeVvoc(project, { default: "prov/m1" }); - context.events.push({ type: "config.updated" }); - await waitFor(() => runtime.lastConfigError() === undefined); - expect(runtime.lastConfigError()).toBeUndefined(); - await promptWork(context, "newborn"); - expect(context.switchCalls.some((call) => call.sessionID === "newborn")).toBe(true); - } finally { - await runtime.release(); - } + const second = await acquireRuntime(context, { enabled: false }); + const after = await second.settings.configFor("ses_child"); + expect(after?.settings).toEqual(before?.settings); + expect(after?.settingsId).toBe(before?.settingsId); + await second.release(); }); - test("a fresh runtime at a moved worktree finds the root capture and does not re-bind", async () => { - await isolateDataHome(); - const project = await createProject({ default: "prov/m1" }); - const context = new FakeNativeContext(project); - const host = new FakeRuntimeHost(context); - context.addModel("prov", "m1"); - context.setDefault("prov", "m1"); - makeBoundSession(context, "root", { model: { providerID: "prov", modelID: "m1" } }); - - const registration = await registerModelRoles(context, { - enabled: true, - runtimeDeps: host.createDeps(), - }); - await promptWork(context, "root"); - await context.invoke("model.request", { - sessionID: "root", - model: { id: "m1", providerID: "prov" }, - kind: "primary", + test("an explicit root metadata change moves only that family", async () => { + await isolateDataHome(); + const project = await createProject({ smart: "prov/m-x" }); + const context = new FakeNativeContext(project); + makeSession(context, "ses_a"); + makeSession(context, "ses_b"); + const runtime = await acquireRuntime(context, { enabled: false }); + await context.invoke("context", { sessionID: "ses_a" }); + await context.invoke("context", { sessionID: "ses_b" }); + await waitFor(() => context.updateCalls.length >= 2); + + // Simulate /vv-settings: rewrite only family A's root record. + const metadataA = context.sessions.get("ses_a")?.metadata as Record; + const currentSettings = metadataA[SESSION_SETTINGS_METADATA_KEY]; + context.sessions.set("ses_a", { + ...context.sessions.get("ses_a")!, + metadata: { + ...metadataA, + [SESSION_SETTINGS_METADATA_KEY]: { + ...(typeof currentSettings === "object" && currentSettings !== null + ? currentSettings + : {}), + profile: "single-session", + }, + }, }); - expect(context.switchCalls).toHaveLength(1); - await registration.dispose(); - // Moved worktree: different directory, same project scope and root session. - const worktree = await createProject({ default: "prov/m1" }); - const moved = new FakeNativeContext(worktree, "proj"); - const movedHost = new FakeRuntimeHost(moved); - moved.addModel("prov", "m1"); - moved.setDefault("prov", "m1"); - makeBoundSession(moved, "root", { - model: { providerID: "prov", modelID: "m1" }, - locationDirectory: worktree, - }); - const movedRegistration = await registerModelRoles(moved, { - enabled: true, - runtimeDeps: movedHost.createDeps(), - }); - await promptWork(moved, "root"); - - expect(moved.switchCalls).toHaveLength(0); - await expect( - moved.invoke("model.request", { - sessionID: "root", - model: { id: "m1", providerID: "prov" }, - kind: "primary", - }), - ).resolves.toBeUndefined(); - await movedRegistration.dispose(); + const a = await runtime.settings.configFor("ses_a"); + const b = await runtime.settings.configFor("ses_b"); + expect(a?.settings.profile).toBe("single-session"); + // Family B keeps the profile it was born with (the project default). + expect(b?.settings.profile).toBe("balanced"); + await runtime.release(); }); - test("a bound family refuses a resolved model that mismatches its capture", async () => { + test("lineage failures resolve to nothing without failing the caller", async () => { await isolateDataHome(); - const project = await createProject({ default: "prov/m1" }); + const project = await createProject({ smart: "prov/m-x" }); const context = new FakeNativeContext(project); - const host = new FakeRuntimeHost(context); - context.addModel("prov", "m1"); - context.setDefault("prov", "m1"); - makeBoundSession(context, "root", { model: { providerID: "prov", modelID: "m1" } }); - - const registration = await registerModelRoles(context, { - enabled: true, - runtimeDeps: host.createDeps(), - }); - await promptWork(context, "root"); - await context.invoke("model.request", { - sessionID: "root", - model: { id: "m1", providerID: "prov" }, - kind: "primary", - }); - - await expect( - context.invoke("model.request", { - sessionID: "root", - model: { id: "m9", providerID: "prov" }, - kind: "primary", - }), - ).rejects.toThrow(/does not match/); - await registration.dispose(); + const runtime = await acquireRuntime(context, { enabled: false }); + expect(await runtime.settings.configFor("ses_ghost")).toBeUndefined(); + await runtime.release(); }); +}); - test("raw OpenCode agent intent overrides the built-in role for a custom agent", async () => { +describe("creation reconciliation", () => { + test("a host-created child adopts the family role model exactly once after a config flip", async () => { await isolateDataHome(); - const project = await createProject({ default: "prov/m1", smart: "prov/m2" }); - // Native OpenCode agent intent lives under `agents`; the V1 `agent` key is - // ignored by the native reader, so the fixture uses the native envelope. - await writeOpenCodeConfig(project, { - agents: { build: { model: "vv-role:smart" }, custom: { model: "prov/custom-literal" } }, - }); + const project = await createProject({ smart: "prov/m-x" }); const context = new FakeNativeContext(project); - const host = new FakeRuntimeHost(context); - context.addAgent("build"); - context.addAgent("custom"); - - const registration = await registerModelRoles(context, { - enabled: true, - runtimeDeps: host.createDeps(), - }); - expect(context.agents.get("build")?.model).toEqual({ - id: Model.ID.make("m2"), - providerID: Provider.ID.make("prov"), - }); - expect(context.agents.get("custom")?.model).toEqual({ - id: Model.ID.make("custom-literal"), - providerID: Provider.ID.make("prov"), - }); - await registration.dispose(); - }); + context.addAgent("vv-controller"); + context.addModel("prov", "m-x"); + context.addModel("prov", "m-y"); + makeSession(context, "ses_root"); + const runtime = await acquireRuntime(context, { enabled: true }); + + await context.invoke("context", { sessionID: "ses_root" }); + await waitFor( + () => + readSettingsFromMetadata( + context.sessions.get("ses_root")?.metadata as Record, + ) !== undefined, + ); - test("binds on the prompt without waiting for an inbox notification and reuses that family", async () => { - await isolateDataHome(); - const project = await createProject({ default: "prov/m1" }); - const context = new FakeNativeContext(project); - const host = new FakeRuntimeHost(context); - context.addModel("prov", "m1"); - context.setDefault("prov", "m1"); - makeBoundSession(context, "root", { model: { providerID: "prov", modelID: "m1" } }); + // The config flips to Y; a new child is created afterwards with the stale Y agent model. + await writeVvoc(project, { smart: "prov/m-y" }); + context.events.push({ type: "config.updated" }); + await waitFor(() => runtime.effectiveConfig().roles.smart === "prov/m-y", 2000); - const registration = await registerModelRoles(context, { - enabled: true, - runtimeDeps: host.createDeps(), + makeSession(context, "ses_child", { parentID: "ses_root", agent: "vv-controller" }); + context.sessionModels.set("ses_child", { providerID: "prov", modelID: "m-y" }); + context.events.push({ + type: "session.created", + data: { + sessionID: "ses_child", + info: { id: "ses_child", parentID: "ses_root" }, + model: { id: "m-y", providerID: "prov" }, + }, }); - await promptWork(context, "root"); - expect(context.switchCalls).toHaveLength(1); - const reloadsBefore = context.modelReloads; + await waitFor(() => context.switchCalls.length > 0, 2000); + expect(context.switchCalls).toEqual([ + { sessionID: "ses_child", model: { providerID: "prov", modelID: "m-x" } }, + ]); - // Inbox and execution notifications no longer participate in binding. + // One-shot: another created event never switches again. context.events.push({ - type: "session.inbox.enqueued", - data: { sessionID: "root", inboxID: "msg-OTHER", item: { type: "user" } }, + type: "session.created", + data: { sessionID: "ses_child", model: { id: "m-y", providerID: "prov" } }, }); - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(context.modelReloads).toBe(reloadsBefore); - - // A bare execution.started (explicit resume) is not admission either. - context.events.push({ type: "session.execution.started", data: { sessionID: "root" } }); - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(context.modelReloads).toBe(reloadsBefore); - - // The first prompt has already bound the family: a later prompt does not switch. - await promptWork(context, "root"); + await new Promise((resolve) => setTimeout(resolve, 50)); expect(context.switchCalls).toHaveLength(1); - await registration.dispose(); + await runtime.release(); }); - test("an old implicit tab follows a new default after a config.updated switch", async () => { + test("auxiliary-metadata children and internal agents are never reconciled", async () => { await isolateDataHome(); - const project = await createProject({ default: "prov/m1" }); + const project = await createProject({ smart: "prov/m-x", fast: "prov/m-fast" }); const context = new FakeNativeContext(project); - const host = new FakeRuntimeHost(context); - context.addModel("prov", "m1"); - context.addModel("prov", "m9"); - context.setDefault("prov", "m1"); - makeBoundSession(context, "old-tab", { model: { providerID: "prov", modelID: "m1" } }); + makeSession(context, "ses_root"); + const runtime = await acquireRuntime(context, { enabled: true }); + await context.invoke("context", { sessionID: "ses_root" }); + await waitFor(() => context.updateCalls.length > 0); - const registration = await registerModelRoles(context, { - enabled: true, - runtimeDeps: host.createDeps(), + makeSession(context, "ses_aux", { + parentID: "ses_root", + agent: "title", + metadata: { vvocAuxiliary: { kind: "title", role: "fast" } }, }); - // The host observed the tab's creation model before the preset switch. context.events.push({ type: "session.created", - data: { sessionID: "old-tab", model: { id: "m1", providerID: "prov" } }, + data: { sessionID: "ses_aux", model: { id: "m-other", providerID: "prov" } }, }); - await new Promise((resolve) => setTimeout(resolve, 10)); - - await writeVvoc(project, { default: "prov/m9" }); - context.events.push({ type: "config.updated" }); - await new Promise((resolve) => setTimeout(resolve, 20)); - - await promptWork(context, "old-tab"); - expect(context.switchCalls.at(-1)?.model.modelID).toBe("m9"); - await registration.dispose(); - }); - - test("title work creates a bound auxiliary child without fork, keeps messages, and fills the title slot", async () => { - await isolateDataHome(); - const project = await createProject({ default: "prov/m1", fast: "prov/m1" }); - const context = new FakeNativeContext(project); - const host = new FakeRuntimeHost(context); - context.addModel("prov", "m1"); - context.setDefault("prov", "m1"); - makeBoundSession(context, "root", { model: { providerID: "prov", modelID: "m1" } }); - - const registration = await registerModelRoles(context, { - enabled: true, - runtimeDeps: host.createDeps(), - }); - await promptWork(context, "root"); - await context.invoke("model.request", { - sessionID: "root", - model: { id: "m1", providerID: "prov" }, - kind: "primary", - }); - const event: { sessionID: string; result?: string; messages?: unknown } = { - sessionID: "root", - messages: [{ role: "user", content: "fix the parser bug" }], - }; - await context.invoke("title", event); - - // A real parented imported child, never a fork or a parentless create. - expect(context.forkCalls).toEqual([]); - expect(context.importCalls).toHaveLength(1); - expect(context.importCalls[0]?.parentID).toBe("root"); - expect(context.importCalls[0]?.model).toEqual({ id: "m1", providerID: "prov" }); - expect(context.importCalls[0]?.metadata).toEqual({ - vvocAuxiliary: { kind: "title", role: "fast" }, - }); - expect(context.generateCalls[0]?.prompt).toContain("fix the parser bug"); - expect(event.result).toMatch(/^generated:ses_[0-9a-f]+$/); - - // The imported child resolves the original family capture and its variant, - // even after the first lease is released and a fresh runtime is acquired. - const childID = context.importCalls[0]?.id as string; - const runtime = await acquireNativeSnapshotRuntime(context, { runtimeDeps: host.createDeps() }); - expect(await runtime.snapshots.familyOf(childID)).toBe("root"); - const rootCapture = await runtime.snapshots.configFor("root"); - const childCapture = await runtime.snapshots.configFor(childID); - expect(childCapture?.snapshotId).toBe(rootCapture?.snapshotId); - expect(childCapture?.roleModels.default).toEqual(rootCapture?.roleModels.default); - - // A supplied title result must survive the later title model.request hook. - await expect( - context.invoke("model.request", { - sessionID: "root", - agent: "title", - model: { id: "m1", providerID: "prov" }, - kind: "title", - }), - ).resolves.toBeUndefined(); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(context.switchCalls).toEqual([]); await runtime.release(); - await registration.dispose(); }); -}); -describe("native lifecycle provenance, resolver coherence and concurrency", () => { - test("model.updated never re-triggers the config watcher, so reloads cannot loop", async () => { + test("disabled role override never reconciles children", async () => { await isolateDataHome(); - const project = await createProject({ default: "prov/m1" }); + const project = await createProject({ smart: "prov/m-x" }); const context = new FakeNativeContext(project); - const host = new FakeRuntimeHost(context); - context.addModel("prov", "m1"); - context.setDefault("prov", "m1"); - makeBoundSession(context, "root", { model: { providerID: "prov", modelID: "m1" } }); - - const registration = await registerModelRoles(context, { - enabled: true, - runtimeDeps: host.createDeps(), - }); - const reloadsBefore = context.modelReloads; - for (let index = 0; index < 8; index += 1) { - context.events.push({ type: "model.updated" }); - context.events.push({ type: "agent.updated" }); - } - await new Promise((resolve) => setTimeout(resolve, 40)); - // Own reload output must not drive the refresh path that emitted it. - expect(context.modelReloads).toBe(reloadsBefore); - await registration.dispose(); - }); - - test("the session agent selects its role (smart) rather than the family default", async () => { - await isolateDataHome(); - const project = await createProject({ default: "prov/m1", smart: "prov/m2" }); - const context = new FakeNativeContext(project); - const host = new FakeRuntimeHost(context); context.addAgent("vv-controller"); - context.addModel("prov", "m1"); - context.addModel("prov", "m2"); - context.setDefault("prov", "m1"); - makeBoundSession(context, "root", { - model: { providerID: "prov", modelID: "m1" }, - agent: "vv-controller", - }); - - const registration = await registerModelRoles(context, { - enabled: true, - runtimeDeps: host.createDeps(), - }); - await promptWork(context, "root"); - expect(context.switchCalls.at(-1)?.model.modelID).toBe("m2"); - await expect( - context.invoke("model.request", { - sessionID: "root", - agent: "vv-controller", - model: { id: "m2", providerID: "prov" }, - kind: "primary", - }), - ).resolves.toBeUndefined(); - await registration.dispose(); - }); - - test("disabled role override binds policy without forcing any model switch", async () => { - await isolateDataHome(); - const project = await createProject({ default: "prov/m1", smart: "prov/m2" }); - const context = new FakeNativeContext(project); - const host = new FakeRuntimeHost(context); - context.addModel("prov", "m1"); - context.addModel("prov", "m2"); - context.setDefault("prov", "m1"); - makeBoundSession(context, "root", { model: { providerID: "prov", modelID: "m1" } }); - - const registration = await registerModelRoles(context, { - enabled: false, - runtimeDeps: host.createDeps(), - }); - await promptWork(context, "root"); - expect(context.switchCalls).toHaveLength(0); - await expect( - context.invoke("model.request", { - sessionID: "root", - model: { id: "m1", providerID: "prov" }, - kind: "primary", - }), - ).resolves.toBeUndefined(); - await registration.dispose(); - }); - - test("an explicit model chosen at creation is preserved over the implicit default", async () => { - await isolateDataHome(); - const project = await createProject({ default: "prov/m1" }); - const context = new FakeNativeContext(project); - const host = new FakeRuntimeHost(context); - context.addModel("prov", "m1"); - context.addModel("prov", "m9"); - context.setDefault("prov", "m1"); - makeBoundSession(context, "root", { model: { providerID: "prov", modelID: "m9" } }); + makeSession(context, "ses_root"); + const runtime = await acquireRuntime(context, { enabled: false }); + await context.invoke("context", { sessionID: "ses_root" }); + await waitFor(() => context.updateCalls.length > 0); - const registration = await registerModelRoles(context, { - enabled: true, - runtimeDeps: host.createDeps(), - }); - // The live host reports the chosen creation model after its hooks are registered. + makeSession(context, "ses_child", { parentID: "ses_root", agent: "vv-controller" }); context.events.push({ type: "session.created", - data: { sessionID: "root", model: { id: "m9", providerID: "prov" } }, + data: { sessionID: "ses_child", model: { id: "m-stale", providerID: "prov" } }, }); - await new Promise((resolve) => setTimeout(resolve, 15)); - await promptWork(context, "root"); - expect(context.switchCalls.at(-1)?.model.modelID).toBe("m9"); - await expect( - context.invoke("model.request", { - sessionID: "root", - model: { id: "m9", providerID: "prov" }, - kind: "primary", - }), - ).resolves.toBeUndefined(); - await registration.dispose(); - }); - - test("a post-bind explicit user switch is neither overwritten nor refused", async () => { - await isolateDataHome(); - const project = await createProject({ default: "prov/m1" }); - const context = new FakeNativeContext(project); - const host = new FakeRuntimeHost(context); - context.addModel("prov", "m1"); - context.addModel("prov", "m9"); - context.setDefault("prov", "m1"); - makeBoundSession(context, "root", { model: { providerID: "prov", modelID: "m1" } }); - - const registration = await registerModelRoles(context, { - enabled: true, - runtimeDeps: host.createDeps(), - }); - await promptWork(context, "root"); - await context.invoke("model.request", { - sessionID: "root", - model: { id: "m1", providerID: "prov" }, - kind: "primary", - }); - const switchesAfterBind = context.switchCalls.length; - - context.events.push({ - type: "session.model.selected", - data: { sessionID: "root", model: { id: "m9", providerID: "prov" } }, - }); - await new Promise((resolve) => setTimeout(resolve, 15)); - await promptWork(context, "root"); - expect(context.switchCalls).toHaveLength(switchesAfterBind); - await expect( - context.invoke("model.request", { - sessionID: "root", - model: { id: "m9", providerID: "prov" }, - kind: "primary", - }), - ).resolves.toBeUndefined(); - await registration.dispose(); - }); - - test("unbound direct generate is refused", async () => { - await isolateDataHome(); - const project = await createProject({ default: "prov/m1" }); - const context = new FakeNativeContext(project); - const host = new FakeRuntimeHost(context); - context.addModel("prov", "m1"); - context.setDefault("prov", "m1"); - makeBoundSession(context, "root", { model: { providerID: "prov", modelID: "m1" } }); - - const registration = await registerModelRoles(context, { - enabled: true, - runtimeDeps: host.createDeps(), - }); - await expect(context.invoke("generate", { sessionID: "root" })).rejects.toThrow(/unbound/); - expect(context.switchCalls).toHaveLength(0); - await registration.dispose(); - }); - - test("the native absent/default variant is accepted without being a named variant", async () => { - await isolateDataHome(); - const project = await createProject({ default: "prov/m1" }); - const context = new FakeNativeContext(project); - const host = new FakeRuntimeHost(context); - context.addModel("prov", "m1"); - context.setDefault("prov", "m1"); - makeBoundSession(context, "root", { - model: { providerID: "prov", modelID: "m1", variant: "default" }, - }); - - const registration = await registerModelRoles(context, { - enabled: true, - runtimeDeps: host.createDeps(), - }); - await promptWork(context, "root"); - // The host resolves `default` to no variant; the guard must treat them as equal. - await expect( - context.invoke("model.request", { - sessionID: "root", - model: { id: "m1", providerID: "prov", variant: "default" }, - kind: "primary", - }), - ).resolves.toBeUndefined(); - await registration.dispose(); - }); - - test("two concurrent families in one runtime keep independent bindings", async () => { - await isolateDataHome(); - const project = await createProject({ default: "prov/m1", smart: "prov/m2" }); - const context = new FakeNativeContext(project); - const host = new FakeRuntimeHost(context); - context.addModel("prov", "m1"); - context.addModel("prov", "m2"); - context.setDefault("prov", "m1"); - makeBoundSession(context, "a", { model: { providerID: "prov", modelID: "m1" } }); - makeBoundSession(context, "b", { - model: { providerID: "prov", modelID: "m2" }, - agent: "vv-controller", - }); - context.addAgent("vv-controller"); - - const registration = await registerModelRoles(context, { - enabled: true, - runtimeDeps: host.createDeps(), - }); - await Promise.all([ - context.invoke("prompt", { sessionID: "a" }), - context.invoke("prompt", { sessionID: "b" }), - ]); - const bySession = new Map(context.switchCalls.map((call) => [call.sessionID, call.model])); - expect(bySession.get("a")?.modelID).toBe("m1"); - expect(bySession.get("b")?.modelID).toBe("m2"); - await registration.dispose(); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(context.switchCalls).toEqual([]); + await runtime.release(); }); +}); - test("the first prompt binds the policy and later config changes cannot replace it", async () => { +describe("config watching and auxiliary work", () => { + test("the event pump contains an invalid config event and recovers on a fixed one", async () => { await isolateDataHome(); - const project = await createProject({ default: "prov/m1" }); + const project = await createProject({ smart: "prov/m-x" }); const context = new FakeNativeContext(project); - const host = new FakeRuntimeHost(context); - context.addModel("prov", "m1"); - context.addModel("prov", "m9"); - context.setDefault("prov", "m1"); - makeBoundSession(context, "root", { model: { providerID: "prov", modelID: "m1" } }); + const runtime = await acquireRuntime(context, { enabled: false }); - const registration = await registerModelRoles(context, { - enabled: true, - runtimeDeps: host.createDeps(), - }); - await promptWork(context, "root"); - expect(context.switchCalls).toHaveLength(1); - - // Later configuration changes affect new families, never this bound one. - await writeVvoc(project, { default: "prov/m9" }); + await writeFile(join(project, ".vvoc", "vvoc.json"), "{broken", "utf8"); context.events.push({ type: "config.updated" }); - await new Promise((resolve) => setTimeout(resolve, 20)); - await promptWork(context, "root"); - expect(context.switchCalls).toHaveLength(1); - await expect( - context.invoke("model.request", { - sessionID: "root", - model: { id: "m1", providerID: "prov" }, - kind: "primary", - }), - ).resolves.toBeUndefined(); - await registration.dispose(); - }); + await waitFor(() => runtime.lastConfigError() !== undefined, 2000); + // The cached effective config stays the last fully read one. + expect(runtime.effectiveConfig().roles.smart).toBe("prov/m-x"); - test("the first prompt wins even if later inbox notifications arrive first", async () => { - await isolateDataHome(); - const project = await createProject({ default: "prov/m1" }); - const context = new FakeNativeContext(project); - const host = new FakeRuntimeHost(context); - context.addModel("prov", "m1"); - context.addModel("prov", "m9"); - context.setDefault("prov", "m1"); - makeBoundSession(context, "root", { model: { providerID: "prov", modelID: "m1" } }); - - const registration = await registerModelRoles(context, { - enabled: true, - runtimeDeps: host.createDeps(), - }); - await promptWork(context, "root"); - await writeVvoc(project, { default: "prov/m9" }); + await writeVvoc(project, { smart: "prov/m-y" }); context.events.push({ type: "config.updated" }); - await new Promise((resolve) => setTimeout(resolve, 20)); - await promptWork(context, "root"); - - // These notifications no longer determine which snapshot is bound. - context.events.push({ - type: "session.inbox.enqueued", - created: 20, - durable: { aggregateID: "root", seq: 2 }, - data: { sessionID: "root", inboxID: "msg-B", item: { type: "user" } }, - }); - await new Promise((resolve) => setTimeout(resolve, 40)); - - // The first prompt owns the snapshot: m1 is authorized and m9 is refused. - await expect( - context.invoke("model.request", { - sessionID: "root", - model: { id: "m1", providerID: "prov" }, - kind: "primary", - }), - ).resolves.toBeUndefined(); - await expect( - context.invoke("model.request", { - sessionID: "root", - model: { id: "m9", providerID: "prov" }, - kind: "primary", - }), - ).rejects.toThrow(/does not match/); - await registration.dispose(); - }); - - test("bind-on-first prompt does not depend on durable event-log replay", async () => { - await isolateDataHome(); - const project = await createProject({ default: "prov/m1" }); - const context = new FakeNativeContext(project); - const host = new FakeRuntimeHost(context); - context.addModel("prov", "m1"); - context.setDefault("prov", "m1"); - makeBoundSession(context, "root", { model: { providerID: "prov", modelID: "m1" } }); - - const registration = await registerModelRoles(context, { - enabled: true, - runtimeDeps: host.createDeps(), - }); - await promptWork(context, "root"); - expect(context.switchCalls).toHaveLength(1); - // The fake host exposes an empty event log. Binding is already durable. - await expect( - context.invoke("model.request", { - sessionID: "root", - model: { id: "m1", providerID: "prov" }, - kind: "primary", - }), - ).resolves.toBeUndefined(); - await registration.dispose(); - }); - - test("a fresh runtime rehydrates a bound family from its durable pointer", async () => { - await isolateDataHome(); - const project = await createProject({ default: "prov/m1" }); - const context = new FakeNativeContext(project); - const host = new FakeRuntimeHost(context); - context.addModel("prov", "m1"); - context.setDefault("prov", "m1"); - makeBoundSession(context, "root", { model: { providerID: "prov", modelID: "m1" } }); - - const registration = await registerModelRoles(context, { - enabled: true, - runtimeDeps: host.createDeps(), - }); - await promptWork(context, "root"); - - const fresh = await acquireNativeSnapshotRuntime(context, { runtimeDeps: host.createDeps() }); - expect((await fresh.snapshots.configFor("root"))?.snapshotId).toBeDefined(); - await fresh.release(); - await registration.dispose(); + await waitFor(() => runtime.effectiveConfig().roles.smart === "prov/m-y", 2000); + expect(runtime.lastConfigError()).toBeUndefined(); + await runtime.release(); }); - test("two source variants of one model capture distinct qualified overlays", async () => { + test("title work creates an auxiliary child bound to the family fast role", async () => { await isolateDataHome(); - const project = await createProject({ default: "prov/m1#low", fast: "prov/m1#high" }); + const project = await createProject({ smart: "prov/m-x", fast: "prov/m-fast" }); const context = new FakeNativeContext(project); - const host = new FakeRuntimeHost(context); - const model = context.addModel("prov", "m1"); - model.settings = { reasoningEffort: "base" }; - model.body = { base_marker: true }; - model.variants.push({ - id: Model.VariantID.make("low"), - settings: { reasoningEffort: "low" }, - body: { tip: "low" }, - }); - model.variants.push({ - id: Model.VariantID.make("high"), - settings: { reasoningEffort: "high" }, - body: { tip: "high" }, - }); - context.setDefault("prov", "m1"); - makeBoundSession(context, "root", { model: { providerID: "prov", modelID: "m1" } }); + makeSession(context, "ses_root"); + const runtime = await acquireRuntime(context, { enabled: false }); + await context.invoke("context", { sessionID: "ses_root" }); + await waitFor(() => context.updateCalls.length > 0); - const registration = await registerModelRoles(context, { - enabled: true, - runtimeDeps: host.createDeps(), - }); - await promptWork(context, "root"); - - const runtime = await acquireNativeSnapshotRuntime(context, { runtimeDeps: host.createDeps() }); - const captures = await runtime.snapshots.captures(); - const variants = captures[0]?.variants ?? []; - const low = variants.find((variant) => variant.sourceVariant === "low"); - const high = variants.find((variant) => variant.sourceVariant === "high"); - // Base is merged with each source variant using native semantics, so two - // roles of the same model keep distinct qualified payloads. - expect(low?.settings).toEqual({ reasoningEffort: "low" }); - expect(high?.settings).toEqual({ reasoningEffort: "high" }); - expect(low?.body).toEqual({ base_marker: true, tip: "low" }); - expect(high?.body).toEqual({ base_marker: true, tip: "high" }); - expect(low?.id).not.toBe(high?.id); + const event: { sessionID: string; result?: string; messages?: unknown } = { + sessionID: "ses_root", + messages: [{ role: "user", text: "hello" }], + }; + await context.invoke("title", event); + await waitFor(() => context.importCalls.length > 0, 2000); + const call = context.importCalls[0]; + expect(call.parentID).toBe("ses_root"); + expect(call.model).toEqual({ id: "m-fast", providerID: "prov" }); + expect(call.metadata).toMatchObject({ vvocAuxiliary: { kind: "title", role: "fast" } }); + expect(context.generateCalls.length).toBe(1); + expect(event.result).toBe(`generated:${call.id}`); await runtime.release(); - await registration.dispose(); }); - test("admitWorkload binds owned generate work before model resolution", async () => { + test("session deletion only clears in-process caches", async () => { await isolateDataHome(); - const project = await createProject({ default: "prov/m1" }); + const project = await createProject({ smart: "prov/m-x" }); const context = new FakeNativeContext(project); - const host = new FakeRuntimeHost(context); - context.addModel("prov", "m1"); - context.setDefault("prov", "m1"); - makeBoundSession(context, "root", { model: { providerID: "prov", modelID: "m1" } }); + makeSession(context, "ses_root"); + const runtime = await acquireRuntime(context, { enabled: false }); + await context.invoke("context", { sessionID: "ses_root" }); + await waitFor(() => context.updateCalls.length > 0); - const registration = await registerModelRoles(context, { - enabled: true, - runtimeDeps: host.createDeps(), - }); - const runtime = await acquireNativeSnapshotRuntime(context, { runtimeDeps: host.createDeps() }); - const location = { - directory: context.location.directory, - projectID: context.location.project.id, - canonical: context.location.project.canonical, - }; - const owned = await runtime.admitWorkload({ sessionID: "root", directory: project, location }); - expect(owned.status).toBe("bound"); - // The late generate hook validates against the owned durable binding. - await expect(context.invoke("generate", { sessionID: "root" })).resolves.toBeUndefined(); + context.events.push({ type: "session.deleted", data: { sessionID: "ses_root" } }); + await new Promise((resolve) => setTimeout(resolve, 50)); + // The family still resolves; the host is the source of truth for lineage. + const view = await runtime.settings.configFor("ses_root"); + expect(view?.familyId).toBe("ses_root"); await runtime.release(); - await registration.dispose(); }); }); -// START_BLOCK_REAL_HOST_SMOKE -/** - * Optional isolated real-host smoke. Runs only when `VVOC_E2E_V2_HOST` points at - * the pinned OpenCode 2.0.18 binary; otherwise it is skipped so ordinary test - * runs stay hermetic. It loads the actual built ModelRolesPlugin (never a - * fixture reimplementation) with a loopback provider and asserts the payload - * that actually reached the provider. - */ -const REAL_HOST = process.env.VVOC_E2E_V2_HOST; -const smokeDescribe = REAL_HOST ? describe : describe.skip; - -/** Reserve and release a loopback TCP port. */ -const getFreePort = (): Promise => - new Promise((resolvePromise, rejectPromise) => { - const server = createServer(); - server.once("error", rejectPromise); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - const port = typeof address === "object" && address !== null ? address.port : 0; - server.close(() => resolvePromise(port)); - }); - }); - -smokeDescribe("real OpenCode 2.0.18 host smoke (actual built plugin)", () => { - const ROOT = join( - process.env.VVOC_E2E_SCRATCH ?? "/tmp/opencode", - `vvoc-t002-smoke-${process.pid}`, - ); - const DIST_PLUGIN = join( - import.meta.dir, - "..", - "..", - "dist", - "plugins", - "model-roles", - "index.js", - ); - const providerTrace = join(ROOT, "trace", "provider.jsonl"); - const observerTrace = join(ROOT, "trace", "observer.jsonl"); - const pids: number[] = []; - - test("loads the built plugin, binds a variant, and sends it in the provider payload", async () => { - const PORT = await getFreePort(); - const HOST_PORT = await getFreePort(); - await rm(ROOT, { recursive: true, force: true }); - await mkdir(join(ROOT, "project", ".vvoc"), { recursive: true }); - await mkdir(join(ROOT, "trace"), { recursive: true }); - await mkdir(join(ROOT, "home"), { recursive: true }); - await mkdir(join(ROOT, "cfg"), { recursive: true }); - await mkdir(join(ROOT, "data"), { recursive: true }); - await mkdir(join(ROOT, "state"), { recursive: true }); - await mkdir(join(ROOT, "cache"), { recursive: true }); - const env = { - PATH: process.env.PATH ?? "/usr/bin:/bin", - HOME: join(ROOT, "home"), - XDG_CONFIG_HOME: join(ROOT, "cfg"), - XDG_DATA_HOME: join(ROOT, "data"), - XDG_STATE_HOME: join(ROOT, "state"), - XDG_CACHE_HOME: join(ROOT, "cache"), - LOOPBACK_API_KEY: "smoke-key", - OPENCODE_DISABLE_MODELS_FETCH: "1", - VVOC_SMOKE_PROVIDER_PORT: String(PORT), - VVOC_SMOKE_PROVIDER_TRACE: providerTrace, - VVOC_SMOKE_TRACE: observerTrace, - }; - - await writeFile( - join(ROOT, "provider.ts"), - `import { appendFileSync, mkdirSync } from "node:fs";\n` + - `import { dirname } from "node:path";\n` + - `const trace = process.env.VVOC_SMOKE_PROVIDER_TRACE;\n` + - `const stamp = (r) => { mkdirSync(dirname(trace), { recursive: true }); appendFileSync(trace, JSON.stringify({ at: Date.now(), ...r }) + "\\n"); };\n` + - `Bun.serve({ hostname: "127.0.0.1", port: Number(process.env.VVOC_SMOKE_PROVIDER_PORT), async fetch(request) {\n` + - ` const body = await request.clone().json().catch(() => ({}));\n` + - ` const model = body?.model ?? "unknown";\n` + - ` stamp({ event: "provider.request", model, body });\n` + - ` const payload = { id: "c", object: "chat.completion", created: 1, model, choices: [{ index: 0, message: { role: "assistant", content: "smoke-ok" }, finish_reason: "stop" }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } };\n` + - ` if (body?.stream) {\n` + - ` const chunk = (delta, f) => "data: " + JSON.stringify({ id: "c", object: "chat.completion.chunk", created: 1, model, choices: [{ index: 0, delta, finish_reason: f }] }) + "\\n\\n";\n` + - ` return new Response(chunk({ role: "assistant" }, null) + chunk({ content: "smoke-ok" }, null) + chunk({}, "stop") + "data: [DONE]\\n\\n", { headers: { "content-type": "text/event-stream" } });\n` + - ` }\n` + - ` return Response.json(payload);\n` + - `} });\n`, - "utf8", - ); - const provider = Bun.spawn([process.execPath, join(ROOT, "provider.ts")], { - env, - stdout: "ignore", - stderr: "ignore", - }); - pids.push(provider.pid); - // provider started before the host so the first request cannot race startup. - await new Promise((resolve) => setTimeout(resolve, 500)); +describe("legacy binding migration", () => { + test("the first runtime start converts legacy bindings into root metadata and prunes directories", async () => { + const dataHome = await isolateDataHome(); + const project = await createProject({ smart: "prov/m-current" }); + const context = new FakeNativeContext(project); + makeSession(context, "ses_root"); - await mkdir(join(ROOT, "plugin"), { recursive: true }); - await mkdir(join(ROOT, "observer"), { recursive: true }); + // Seed the legacy layout: one live family, one dead family. + const scope = createHash("sha256").update("proj").digest("hex").slice(0, 32); + const bindingsDir = join(dataHome, "vvoc", "bindings", scope); + await mkdir(join(bindingsDir, "families"), { recursive: true }); + await mkdir(join(bindingsDir, "policies"), { recursive: true }); await writeFile( - join(ROOT, "plugin", "package.json"), - JSON.stringify({ name: "vvoc-smoke-plugin", private: true, version: "0.0.0" }), + join(bindingsDir, "families", "ses_root.json"), + JSON.stringify({ familyId: "ses_root", snapshotHash: "hash1", boundAt: "2026-01-01" }), "utf8", ); await writeFile( - join(ROOT, "plugin", "index.ts"), - `import p from "${DIST_PLUGIN}";\nexport default p;\n`, + join(bindingsDir, "families", "ses_dead.json"), + JSON.stringify({ familyId: "ses_dead", snapshotHash: "hash2", boundAt: "2026-01-01" }), "utf8", ); await writeFile( - join(ROOT, "observer", "package.json"), - JSON.stringify({ name: "vvoc-smoke-observer", private: true, version: "0.0.0" }), - "utf8", - ); - await writeFile( - join(ROOT, "observer", "index.ts"), - `import { appendFileSync } from "node:fs";\n` + - `export default { id: "vvoc.smoke-observer", async setup(ctx) {\n` + - ` const trace = process.env.VVOC_SMOKE_TRACE;\n` + - ` const stamp = (e, f = {}) => appendFileSync(trace, JSON.stringify({ at: Date.now(), event: e, ...f }) + "\\n");\n` + - ` await ctx.session.hook("http.request", async (i) => { const body = await i.request.clone().json().catch(() => undefined); stamp("http.request", { model: i.model, body }); });\n` + - `} };\n`, - "utf8", - ); - const project = join(ROOT, "project"); - await writeFile( - join(project, "opencode.json"), + join(bindingsDir, "policies", "hash1.json"), JSON.stringify({ - model: "loopback/seam-smart", - providers: { - loopback: { - name: "Smoke Loopback", - package: "@opencode/ai/providers/openai-compatible", - env: ["LOOPBACK_API_KEY"], - settings: { baseURL: `http://127.0.0.1:${PORT}/v1`, provider: "loopback" }, - models: { - "seam-smart": { - name: "Smoke Smart", - settings: { reasoningEffort: "low" }, - variants: [ - { - id: "override", - settings: { reasoningEffort: "high" }, - body: { smoke_variant: "override" }, - }, - { - id: "plain", - settings: { reasoningEffort: "minimal" }, - body: { smoke_variant: "plain" }, - }, - ], - }, - "seam-fast": { name: "Smoke Fast" }, - }, - }, + roleModels: { smart: { providerID: "prov", modelID: "m-legacy" } }, + agentBindings: [], + policy: { + ...createDefaultVvocConfig(), + roles: { smart: "prov/m-legacy" }, + orchestration: { profile: "orchestrated" }, }, - plugins: [{ package: join(ROOT, "plugin") }, { package: join(ROOT, "observer") }], }), "utf8", ); - await writeFile( - join(project, ".vvoc", "vvoc.json"), - renderVvocConfig({ - ...createDefaultVvocConfig(), - roles: { - default: "loopback/seam-smart#override", - smart: "loopback/seam-smart#override", - fast: "loopback/seam-smart#plain", - reviewer: "loopback/seam-smart#plain", - }, - }), - "utf8", + await mkdir(join(dataHome, "vvoc", "snapshots", scope), { recursive: true }); + await mkdir(join(dataHome, "vvoc", "rebind"), { recursive: true }); + + const runtime = await acquireRuntime(context, { enabled: false }); + await waitFor( + () => + readSettingsFromMetadata( + context.sessions.get("ses_root")?.metadata as Record, + ) !== undefined, + 2000, ); - - const host = Bun.spawn( - [ - REAL_HOST as string, - "serve", - "--service", - "--hostname", - "127.0.0.1", - "--port", - String(HOST_PORT), - "--log-level", - "error", - ], - { env, stdout: "ignore", stderr: "pipe" }, + const stored = readSettingsFromMetadata( + context.sessions.get("ses_root")?.metadata as Record, ); - pids.push(host.pid); - let hostStderr = ""; - void new Response(host.stderr as ReadableStream).text().then((text) => { - hostStderr = text; + expect(stored && "settings" in stored ? stored.settings.roles : undefined).toEqual({ + smart: "prov/m-legacy", }); + // The runtime itself resolves the converted record after a restart too. + const view = await runtime.settings.configFor("ses_root"); + expect(view?.settings.roles.smart).toBe("prov/m-legacy"); + await runtime.release(); - const servicePath = join(ROOT, "state", "opencode", "service.json"); - let password: string | undefined; - for (let attempt = 0; attempt < 80; attempt += 1) { - try { - password = (JSON.parse(await readFile(servicePath, "utf8")) as { password: string }) - .password; - if (password) break; - } catch { - /* not written yet */ - } - await new Promise((resolve) => setTimeout(resolve, 250)); - } - expect(password, `host did not register; stderr: ${hostStderr}`).toBeTruthy(); - const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`; - const api = async (path: string, init: RequestInit = {}) => - fetch(`http://127.0.0.1:${HOST_PORT}${path}`, { - ...init, - headers: { - authorization: auth, - "content-type": "application/json", - "x-opencode-directory": project, - ...init.headers, - }, - }); - const created = (await ( - await api("/api/session", { - method: "POST", - body: JSON.stringify({ location: { directory: project } }), - }) - ).json()) as { - data: { id: string }; - }; - const sessionID = created.data.id; - await new Promise((resolve) => setTimeout(resolve, 1500)); - // A first prompt whose native preparation is rejected (missing attachment) - // after the prompt hook stages a candidate must not block the next prompt. - const rejectedPrompt = await api(`/api/session/${sessionID}/prompt`, { - method: "POST", - body: JSON.stringify({ - text: "rejected preparation", - files: [ - { - uri: `file://${join(ROOT, "definitely-missing-attachment.txt")}`, - name: "missing.txt", - }, - ], - }), - }); - expect(rejectedPrompt.status).toBeGreaterThanOrEqual(400); - await new Promise((resolve) => setTimeout(resolve, 500)); - - await api(`/api/session/${sessionID}/prompt`, { - method: "POST", - body: JSON.stringify({ text: "smoke payload" }), - }); - const deadline = Date.now() + 20000; - let variant: string | undefined; - for (;;) { - const info = (await (await api(`/api/session/${sessionID}`)).json()) as { - data: { model?: { id?: string; variant?: string }; time?: { idle?: number } }; - }; - variant = info.data.model?.variant; - if (info.data.time?.idle !== undefined) break; - if (Date.now() > deadline) break; - await new Promise((resolve) => setTimeout(resolve, 400)); - } - - const providerLines = (await readFile(providerTrace, "utf8")) - .split("\n") - .filter(Boolean) - .map( - (line) => - JSON.parse(line) as { - event?: string; - model?: string; - body?: { reasoning_effort?: string; smoke_variant?: string }; - }, - ); - const dispatched = providerLines.filter((line) => line.event === "provider.request"); - expect(dispatched.length).toBeGreaterThan(0); - // The actual provider payload carried the source variant's merged override, - // not merely the base model settings. - expect( - dispatched.some( - (line) => line.body?.smoke_variant === "override" && line.body?.reasoning_effort === "high", - ), - ).toBe(true); - expect(variant).toMatch(/\.seam-smart\.override$/); - const snapshotPrefix = (variant ?? "").replace(/\.seam-smart\.override$/, ""); - expect(snapshotPrefix).toMatch(/^[0-9a-f]{16}$/); - - // The auxiliary title child is a REAL parented native session that resolved - // the same immutable family capture (same snapshot id prefix). - const sessionInfo = async (id: string) => - ( - (await (await api(`/api/session/${id}`)).json()) as { - data: { - id: string; - parentID?: string; - model?: { id?: string; variant?: string }; - metadata?: Record; - }; - } - ).data; - const listed = (await ( - await api(`/api/session?location[directory]=${encodeURIComponent(project)}`) - ).json()) as { - data: Array<{ id: string; parentID?: string }>; - }; - const child = listed.data.find((entry) => entry.parentID === sessionID); - expect(child, "auxiliary child must be a parented native session").toBeDefined(); - const childInfo = await sessionInfo((child as { id: string }).id); - expect(childInfo.parentID).toBe(sessionID); - // The child is the same model with the fast role's distinct source variant. - expect(childInfo.model?.variant).toMatch( - new RegExp(`^${snapshotPrefix}\\.seam-smart\\.plain$`), - ); - expect(childInfo.metadata).toEqual({ vvocAuxiliary: { kind: "title", role: "fast" } }); - // Two roles of one model sent two distinct qualified payloads. - expect( - dispatched.some( - (line) => line.body?.smoke_variant === "plain" && line.body?.reasoning_effort === "minimal", - ), - ).toBe(true); - - // Changing the source policy gives NEW work a NEW snapshot while the already - // bound root family (and its child) keep the original captured variant/payload. - await writeFile( - join(project, ".vvoc", "vvoc.json"), - renderVvocConfig({ - ...createDefaultVvocConfig(), - roles: { - default: "loopback/seam-smart#plain", - smart: "loopback/seam-smart#plain", - fast: "loopback/seam-smart#plain", - reviewer: "loopback/seam-smart#plain", - }, - }), - "utf8", - ); - const created2 = (await ( - await api("/api/session", { - method: "POST", - body: JSON.stringify({ location: { directory: project } }), - }) - ).json()) as { data: { id: string } }; - const session2 = created2.data.id; - await new Promise((resolve) => setTimeout(resolve, 1200)); - await api(`/api/session/${session2}/prompt`, { - method: "POST", - body: JSON.stringify({ text: "smoke payload two" }), - }); - const deadline2 = Date.now() + 20000; - let variant2: string | undefined; - for (;;) { - const info = await sessionInfo(session2); - variant2 = info.model?.variant; - if ((info as { time?: { idle?: number } }).time?.idle !== undefined) break; - if (Date.now() > deadline2) break; - await new Promise((resolve) => setTimeout(resolve, 400)); - } - expect(variant2).toBeDefined(); - expect(variant2).not.toBe(variant); - expect(variant2).toMatch(/\.seam-smart\.plain$/); - const boundRoot = await sessionInfo(sessionID); - expect(boundRoot.model?.variant).toBe(variant); - - // Real-host race control: two rapid inputs to one session retain its - // bind-on-first-work policy. - const created3 = (await ( - await api("/api/session", { - method: "POST", - body: JSON.stringify({ location: { directory: project } }), - }) - ).json()) as { data: { id: string } }; - const session3 = created3.data.id; - await new Promise((resolve) => setTimeout(resolve, 1200)); - await api(`/api/session/${session3}/prompt`, { - method: "POST", - body: JSON.stringify({ text: "race one" }), - }); - await api(`/api/session/${session3}/prompt`, { - method: "POST", - body: JSON.stringify({ text: "race two" }), - }); - const deadline3 = Date.now() + 20000; - let variant3: string | undefined; - for (;;) { - const info = await sessionInfo(session3); - variant3 = info.model?.variant; - if ((info as { time?: { idle?: number } }).time?.idle !== undefined) break; - if (Date.now() > deadline3) break; - await new Promise((resolve) => setTimeout(resolve, 400)); - } - expect(variant3).toMatch(/\.seam-smart\.plain$/); - - // The bound family kept dispatching its original captured variant payload. - const afterChange = (await readFile(providerTrace, "utf8")) - .split("\n") - .filter(Boolean) - .map( - (line) => - JSON.parse(line) as { - event?: string; - body?: { smoke_variant?: string }; - }, - ) - .filter((line) => line.event === "provider.request"); - expect(afterChange.some((line) => line.body?.smoke_variant === "plain")).toBe(true); - - // A loop would have produced a flood of own-reload events. - const observerTypes = (await readFile(observerTrace, "utf8")) - .split("\n") - .filter(Boolean) - .map((line) => JSON.parse(line) as { type?: string }); - expect(observerTypes.filter((entry) => entry.type === "model.updated").length).toBeLessThan(50); - }, 90000); - - afterAll(async () => { - for (const pid of pids) { - try { - process.kill(pid, "SIGTERM"); - } catch { - /* already gone */ - } - } - await new Promise((resolve) => setTimeout(resolve, 300)); - await rm(ROOT, { recursive: true, force: true }); + // Second start: migration is a no-op with the directories gone. + const secondRuntime = await acquireRuntime(context, { enabled: false }); + const again = await secondRuntime.settings.configFor("ses_root"); + expect(again?.settings.roles.smart).toBe("prov/m-legacy"); + await secondRuntime.release(); + const { readdir } = await import("node:fs/promises"); + await expect(readdir(join(dataHome, "vvoc", "bindings", scope))).rejects.toBeDefined(); + await expect(readdir(join(dataHome, "vvoc", "rebind"))).rejects.toBeDefined(); }); }); -// END_BLOCK_REAL_HOST_SMOKE diff --git a/src/plugins/model-roles/index.ts b/src/plugins/model-roles/index.ts index 052ec6a9..1469f7ee 100644 --- a/src/plugins/model-roles/index.ts +++ b/src/plugins/model-roles/index.ts @@ -1,8 +1,8 @@ // FILE: src/plugins/model-roles/index.ts // VERSION: 2.0.0 // START_MODULE_CONTRACT -// PURPOSE: Native ModelRolesPlugin that delegates to the shared native snapshot runtime, enabling vvoc role overriding when the model-roles toggle is on. -// SCOPE: Native Plugin.define default export, plugin-toggle check, shared runtime acquisition, role-override enablement, and scoped release. Core policy capture, admission, guarding, title auxiliary work and native config watching live in the shared runtime, so this toggle never disables other plugins' policy capture or guards. No V1 fake client, no singleton startup config, no forced location reload, no stateless generation, and no cosmetic compatibility cast. +// PURPOSE: Native ModelRolesPlugin that delegates to the shared native runtime, enabling vvoc role overriding for newly created sessions when the model-roles toggle is on. +// SCOPE: Native Plugin.define default export, plugin-toggle check, shared runtime acquisition, role-override enablement, and scoped release. Family settings resolution, auxiliary work, and creation-level role application live in the shared runtime, so this toggle never disables other plugins' settings resolution. No V1 fake client, no singleton startup config, no forced location reload, no stateless generation, and no cosmetic compatibility cast. // DEPENDS: [@opencode/plugin, src/lib/config-layers.ts, src/lib/plugin-toggle-config.ts, src/runtime/context.ts] // LINKS: [M-PLUGIN-MODEL-ROLES, M-NATIVE-RUNTIME, V-M-PLUGIN-MODEL-ROLES] // ROLE: RUNTIME @@ -11,13 +11,14 @@ // // START_MODULE_MAP // ModelRolesRegistration - Releasable plugin-owned registration. -// registerModelRoles - Enable or disable vvoc role overriding on the shared native snapshot runtime. +// registerModelRoles - Enable or disable vvoc role overriding on the shared native runtime. // ModelRolesPlugin - Native Plugin.define object for vvoc model-roles. // default - Default export: the native ModelRolesPlugin object. // END_MODULE_MAP // // START_CHANGE_SUMMARY -// LAST_CHANGE: [C-OPENCODE-V2-NATIVE T-002 - Delegated to acquireNativeSnapshotRuntime so the toggle only controls role overriding.] +// LAST_CHANGE: [C-SESSION-OWNED-SETTINGS T-003 - Kept the toggle semantics on the settings-based runtime: role overriding now applies to newly created sessions only, and family settings resolution is unaffected.] +// PREVIOUS: [C-OPENCODE-V2-NATIVE T-002 - Delegated to acquireNativeSnapshotRuntime so the toggle only controls role overriding.] // END_CHANGE_SUMMARY import { Plugin } from "@opencode/plugin"; @@ -37,9 +38,9 @@ export interface ModelRolesRegistration { } /** - * Enable or disable vvoc role overriding on the shared native snapshot runtime. - * Policy capture, admission, guarding and auxiliary work stay active regardless, - * so another plugin sharing the same context keeps its stable policy service. + * Enable or disable vvoc role overriding on the shared native runtime. Family + * settings resolution and auxiliary work stay active regardless, so another + * plugin sharing the same context keeps its stable settings service. */ export async function registerModelRoles( ctx: NativeSnapshotContext, @@ -47,7 +48,6 @@ export async function registerModelRoles { const runtime = await acquireNativeSnapshotRuntime(ctx, { ...(options.runtimeDeps === undefined ? {} : { runtimeDeps: options.runtimeDeps }), - ...(options.store === undefined ? {} : { store: options.store }), ...(options.now === undefined ? {} : { now: options.now }), }); await runtime.setRoleOverride(options.enabled); diff --git a/src/plugins/peak-hours/index.test.ts b/src/plugins/peak-hours/index.test.ts index 6051bddd..1e35fc87 100644 --- a/src/plugins/peak-hours/index.test.ts +++ b/src/plugins/peak-hours/index.test.ts @@ -110,10 +110,9 @@ async function makePlugin( const entry = options.entry ?? baseEntry(); const config = configFor(policy === "disabled" ? false : entry); const fakeRuntime = { - snapshots: { + settings: { configFor: async () => policy === "unknown" ? undefined : { familyId: "fam-1", vvoc: config }, - accept: async () => ({ status: "unbound" }), }, release: async () => { released = true; diff --git a/src/plugins/peak-hours/index.ts b/src/plugins/peak-hours/index.ts index 7f92823f..26ef3a0a 100644 --- a/src/plugins/peak-hours/index.ts +++ b/src/plugins/peak-hours/index.ts @@ -31,7 +31,7 @@ import { type NativeSnapshotContext, type NativeSnapshotRuntime, } from "../../runtime/context.js"; -import type { FamilyCapture } from "../../runtime/types.js"; +import type { FamilySettingsView } from "../../runtime/session-settings.js"; import { findActivePeak, formatPeakEndTime, @@ -195,25 +195,15 @@ async function resolveEntry( runtime: NativeSnapshotRuntime, sessionID: string, ): Promise<{ entry: PeakHoursEntryConfig; warnings: string[] } | undefined> { - const read = async (): Promise => { - try { - return await runtime.snapshots.configFor(sessionID); - } catch { - return undefined; - } - }; - let capture = await read(); - if (capture === undefined) { - try { - await runtime.snapshots.accept({ sessionID }); - } catch { - // fall through to the second read; absence is unknown policy below - } - capture = await read(); + let view: FamilySettingsView | undefined; + try { + view = await runtime.settings.configFor(sessionID); + } catch { + return undefined; } - if (capture === undefined) return undefined; - if (!isVvocPluginEnabled(capture.vvoc, "peak-hours")) return undefined; - return parsePeakHoursEntry(capture.vvoc.plugins?.["peak-hours"]); + if (view === undefined) return undefined; + if (!isVvocPluginEnabled(view.vvoc, "peak-hours")) return undefined; + return parsePeakHoursEntry(view.vvoc.plugins?.["peak-hours"]); } // END_BLOCK_POLICY diff --git a/src/plugins/secrets-redaction/index.test.ts b/src/plugins/secrets-redaction/index.test.ts index ab305b13..7dd54b61 100644 --- a/src/plugins/secrets-redaction/index.test.ts +++ b/src/plugins/secrets-redaction/index.test.ts @@ -33,7 +33,7 @@ // END_MODULE_MAP // // START_CHANGE_SUMMARY -// LAST_CHANGE: [C-OPENCODE-V2-NATIVE T-009-SECRETS - Reconciled the distinct-context real-host smoke with the pinned 2.0.18 host: the T-009 cross-context coordination publishes the family capture exactly once across subpath-registered contexts, so secrets-first distinct contexts now complete a turn with full redact -> restore evidence and no SnapshotAdmissionError; the test asserts the reconciled succeeded outcome instead of the pre-coordination failure. Prior T-009-SECRETS: deep-frozen tool-input regressions (array and nested-object restore through toolBefore, no-placeholder equivalent copy, cyclic input cycle preservation, and frozen tool-call redaction all assert the host-provided original is never mutated and the handler rebinds to an owned copy); earlier MID-REPAIR-2: per-native-session WebSocket framing regressions.] +// LAST_CHANGE: [C-OPENCODE-V2-NATIVE T-009-SECRETS - Reconciled the distinct-context real-host smoke with the pinned 2.0.18 host: the T-009 cross-context coordination publishes the family capture exactly once across subpath-registered contexts, so secrets-first distinct contexts now complete a turn with full redact -> restore evidence and no family-binding admission failure; the test asserts the reconciled succeeded outcome instead of the pre-coordination failure. Prior T-009-SECRETS: deep-frozen tool-input regressions (array and nested-object restore through toolBefore, no-placeholder equivalent copy, cyclic input cycle preservation, and frozen tool-call redaction all assert the host-provided original is never mutated and the handler rebinds to an owned copy); earlier MID-REPAIR-2: per-native-session WebSocket framing regressions.] // END_CHANGE_SUMMARY import { describe, expect, test } from "bun:test"; @@ -552,9 +552,8 @@ describe("SecretsRedactionPlugin registration", () => { const disposed: string[] = []; let released = false; const fakeRuntime = { - snapshots: { + settings: { configFor: async () => capture, - accept: async () => ({ status: "unbound" }), }, release: async () => { released = true; @@ -961,11 +960,12 @@ realHostDescribe("real OpenCode 2.0.18 host smoke (built secrets-redaction)", () // Distinct Plugin.Context objects (separately registered subpath packages) // exercise the T009 aggregate mode across context boundaries. Drift note: // an earlier revision of this test expected outcome "failed" plus a - // SnapshotAdmissionError, documenting the pre-coordination state where each - // distinct context raced its own family capture. The T-009 coordination - // (app/location-identity coordinator plus the shared per-family publication - // lock) reconciled the aggregate mode, and the pinned 2.0.18 host completes - // the secrets-first distinct-context turn: redaction must hold end to end. + // family-binding admission error, documenting the pre-coordination state + // where each distinct context raced its own family capture. The T-009 + // coordination (app/location-identity coordinator plus the shared + // per-family publication lock) reconciled the aggregate mode, and the + // pinned host completes the secrets-first distinct-context turn: redaction + // must hold end to end. const scenario = await startScenario([ { name: "secrets", @@ -983,7 +983,8 @@ realHostDescribe("real OpenCode 2.0.18 host smoke (built secrets-redaction)", () expect(entries.find((entry) => entry.type === "idle")?.outcome).toBe("succeeded"); await new Promise((resolve) => setTimeout(resolve, 1500)); const hostLog = await readFile(scenario.hostLogPath, "utf8").catch(() => ""); - expect(hostLog).not.toContain("SnapshotAdmissionError"); + expect(hostLog).not.toContain("admission"); + expect(hostLog).not.toContain("family binding"); const trace = await readFile(scenario.tracePath, "utf8").catch(() => ""); expect(trace).toContain(smokePlaceholder()); expect(trace).not.toContain(SMOKE_SECRET); diff --git a/src/plugins/secrets-redaction/index.ts b/src/plugins/secrets-redaction/index.ts index c4c6f801..bdabb33a 100644 --- a/src/plugins/secrets-redaction/index.ts +++ b/src/plugins/secrets-redaction/index.ts @@ -39,7 +39,7 @@ import { type NativeSnapshotContext, type NativeSnapshotRuntime, } from "../../runtime/context.js"; -import type { FamilyCapture } from "../../runtime/types.js"; +import type { FamilySettingsView } from "../../runtime/session-settings.js"; import { isVvocPluginEnabled } from "../../lib/plugin-toggle-config.js"; import { resolveSecretsRedactionRuntimeConfig, type SecretsRedactionConfig } from "./config.js"; import { buildPatternSet, type PatternSet } from "./patterns.js"; @@ -354,37 +354,25 @@ function createHandlers(deps: SecretsRedactionDependencies): { // END_BLOCK_HANDLERS // START_BLOCK_PLUGIN_ENTRY -/** Resolve the bound-family secrets policy; never falls back to current/default config. */ +/** Resolve the family-settings secrets policy; unresolvable settings leave redaction off. */ async function resolvePolicy( runtime: NativeSnapshotRuntime, sessionID: string, ): Promise { - const read = async (): Promise => { - try { - return await runtime.snapshots.configFor(sessionID); - } catch { - return undefined; - } - }; - let capture = await read(); - if (capture === undefined) { - // A first accepted workload may be persisted but not yet published; reconcile - // through the native acceptance service before deciding, then read again. - try { - await runtime.snapshots.accept({ sessionID }); - } catch { - // fall through to the second read; absence blocks below - } - capture = await read(); + let view: FamilySettingsView | undefined; + try { + view = await runtime.settings.configFor(sessionID); + } catch { + return undefined; } - if (capture === undefined) return undefined; - if (!isVvocPluginEnabled(capture.vvoc, "secrets-redaction")) return "disabled"; + if (view === undefined) return undefined; + if (!isVvocPluginEnabled(view.vvoc, "secrets-redaction")) return "disabled"; const resolved = resolveSecretsRedactionRuntimeConfig({ - config: capture.vvoc, + config: view.vvoc, source: { kind: "project" }, warnings: [], }); - return { key: capture.familyId, config: resolved.config }; + return { key: view.familyId, config: resolved.config }; } function createConsoleLog(): SecretsRedactionDependencies["log"] { diff --git a/src/plugins/spec-guard/index.ts b/src/plugins/spec-guard/index.ts index efd605d2..4f244c26 100644 --- a/src/plugins/spec-guard/index.ts +++ b/src/plugins/spec-guard/index.ts @@ -49,7 +49,7 @@ import { type SpecLintVerdict, } from "../../lib/spec-lint.js"; import { createSpecLintCache, type SpecLintCache } from "../../lib/spec-lint-cache.js"; -import type { FamilyCapture } from "../../runtime/types.js"; +import type { FamilySettingsView } from "../../runtime/session-settings.js"; // START_BLOCK_CONSTANTS export type SpecGuardMode = "warn" | "enforce"; @@ -354,31 +354,20 @@ export function createSpecGuardHandlers(deps: SpecGuardHookDependencies): SpecGu // END_BLOCK_HANDLERS // START_BLOCK_PLUGIN_ENTRY -/** Resolve the bound-family mode; unbound work fails closed to `"off"`. */ +/** Resolve the family-settings mode; unresolvable settings fail closed to `"unknown"`. */ async function resolveFamilyMode( runtime: NativeSnapshotRuntime, sessionID: string, ): Promise { - const read = async (): Promise => { - try { - return await runtime.snapshots.configFor(sessionID); - } catch { - return undefined; - } - }; - let capture = await read(); - if (capture === undefined) { - // A first accepted workload may be persisted but not yet published. - try { - await runtime.snapshots.accept({ sessionID }); - } catch { - // fall through to the second read - } - capture = await read(); + let view: FamilySettingsView | undefined; + try { + view = await runtime.settings.configFor(sessionID); + } catch { + return "unknown"; } - if (capture === undefined) return "unknown"; - if (!isVvocPluginEnabled(capture.vvoc, "spec-guard")) return "off"; - const entry = capture.vvoc.plugins?.["spec-guard"]; + if (view === undefined) return "unknown"; + if (!isVvocPluginEnabled(view.vvoc, "spec-guard")) return "off"; + const entry = view.vvoc.plugins?.["spec-guard"]; if (entry && typeof entry === "object" && entry.mode === "enforce") return "enforce"; return "warn"; } diff --git a/src/plugins/system-context-injection.integration.test.ts b/src/plugins/system-context-injection.integration.test.ts index 631d6a7f..34d214c6 100644 --- a/src/plugins/system-context-injection.integration.test.ts +++ b/src/plugins/system-context-injection.integration.test.ts @@ -78,10 +78,9 @@ async function makeHarness( const hooks = new Map Promise | void>(); let released = false; const fakeRuntime = { - snapshots: { + settings: { configFor: async () => policy === "unknown" ? undefined : { familyId: "fam-1", vvoc: config }, - accept: async () => ({ status: "unbound" }), }, release: async () => { released = true; diff --git a/src/plugins/system-context-injection/index.ts b/src/plugins/system-context-injection/index.ts index eca80f0b..620b8c4c 100644 --- a/src/plugins/system-context-injection/index.ts +++ b/src/plugins/system-context-injection/index.ts @@ -33,7 +33,7 @@ import { type NativeSnapshotContext, type NativeSnapshotRuntime, } from "../../runtime/context.js"; -import type { FamilyCapture } from "../../runtime/types.js"; +import type { FamilySettingsView } from "../../runtime/session-settings.js"; const BUILT_IN_SUBAGENTS = ["general"] as const; const PLUGIN_MANAGED_SUBAGENTS = ["guardian"] as const; @@ -267,26 +267,16 @@ function appendTailContext( async function resolveCapturedConfig( runtime: NativeSnapshotRuntime, sessionID: string, -): Promise { - const read = async (): Promise => { - try { - return await runtime.snapshots.configFor(sessionID); - } catch { - return undefined; - } - }; - let capture = await read(); - if (capture === undefined) { - try { - await runtime.snapshots.accept({ sessionID }); - } catch { - // fall through to the second read; absence is unknown policy below - } - capture = await read(); +): Promise { + let view: FamilySettingsView | undefined; + try { + view = await runtime.settings.configFor(sessionID); + } catch { + return undefined; } - if (capture === undefined) return undefined; - if (!isVvocPluginEnabled(capture.vvoc, "system-context-injection")) return undefined; - return capture.vvoc; + if (view === undefined) return undefined; + if (!isVvocPluginEnabled(view.vvoc, "system-context-injection")) return undefined; + return view.vvoc; } // END_BLOCK_POLICY diff --git a/src/plugins/tool-history-compaction.integration.test.ts b/src/plugins/tool-history-compaction.integration.test.ts index 17f5c239..f0e65d9d 100644 --- a/src/plugins/tool-history-compaction.integration.test.ts +++ b/src/plugins/tool-history-compaction.integration.test.ts @@ -80,12 +80,11 @@ async function createHarness( let released = false; const capture = { familyId: "fam-1", vvoc: config }; const fakeRuntime = { - snapshots: { + settings: { configFor: async (sessionID: string) => { if (options.resolve !== undefined) return options.resolve(sessionID); return policy === "unknown" ? undefined : capture; }, - accept: async () => ({ status: "unbound" }), }, client: async () => { if (options.clientThrows) throw new Error("client unavailable"); diff --git a/src/plugins/tool-history-compaction/index.ts b/src/plugins/tool-history-compaction/index.ts index e3adfdeb..27bed9ad 100644 --- a/src/plugins/tool-history-compaction/index.ts +++ b/src/plugins/tool-history-compaction/index.ts @@ -29,7 +29,7 @@ import { type NativeSnapshotContext, type NativeSnapshotRuntime, } from "../../runtime/context.js"; -import type { FamilyCapture } from "../../runtime/types.js"; +import type { FamilySettingsView } from "../../runtime/session-settings.js"; import { parseToolHistoryCompactionEntry, type ResolvedToolHistoryCompactionEntry, @@ -69,41 +69,29 @@ function createConsoleLog(): (event: ToolHistoryCompactionDiagnostic) => void { // START_BLOCK_POLICY /** - * Resolve the bound-family compaction policy; never falls back to current or - * default config. An unbound but staged family is reconciled through native - * acceptance before a second read. Unknown policy compacts nothing. + * Resolve the family-settings compaction policy for one session: plugin + * toggle and entry detail resolve from the projected family configuration. + * Unresolvable settings compact nothing. */ async function resolvePolicy( runtime: NativeSnapshotRuntime, sessionID: string, log: (event: ToolHistoryCompactionDiagnostic) => void, ): Promise { - const read = async (): Promise => { - try { - return await runtime.snapshots.configFor(sessionID); - } catch { - return undefined; - } - }; - let capture = await read(); - if (capture === undefined) { - try { - await runtime.snapshots.accept({ sessionID }); - } catch { - // fall through to the second read; absence is unknown policy below - } - capture = await read(); + let view: FamilySettingsView | undefined; + try { + view = await runtime.settings.configFor(sessionID); + } catch { + return undefined; } - if (capture === undefined) return undefined; - if (!isVvocPluginEnabled(capture.vvoc, "tool-history-compaction")) return { enabled: false }; + if (view === undefined) return undefined; + if (!isVvocPluginEnabled(view.vvoc, "tool-history-compaction")) return { enabled: false }; try { - const entry = parseToolHistoryCompactionEntry( - capture.vvoc.plugins?.["tool-history-compaction"], - ); + const entry = parseToolHistoryCompactionEntry(view.vvoc.plugins?.["tool-history-compaction"]); return { enabled: entry.enabled, config: entry.config }; } catch { - // A corrupt captured entry is unknown policy, never a silent rewrite with defaults. - log({ level: "warn", message: "captured compaction config could not be resolved; skipping" }); + // A corrupt entry is unknown policy, never a silent rewrite with defaults. + log({ level: "warn", message: "family compaction config could not be resolved; skipping" }); return undefined; } } diff --git a/src/plugins/web-tools.integration.test.ts b/src/plugins/web-tools.integration.test.ts index 60f68543..d8dee32e 100644 --- a/src/plugins/web-tools.integration.test.ts +++ b/src/plugins/web-tools.integration.test.ts @@ -114,9 +114,8 @@ async function createHarness( let released = false; const capture = { familyId: "fam-1", vvoc: config }; const fakeRuntime = { - snapshots: { + settings: { configFor: async () => (policy === "unknown" ? undefined : capture), - accept: async () => ({ status: "unbound" }), }, permissions: permission, effectiveConfig: () => ({ diff --git a/src/plugins/web-tools/index.ts b/src/plugins/web-tools/index.ts index 41079540..1c16d6e2 100644 --- a/src/plugins/web-tools/index.ts +++ b/src/plugins/web-tools/index.ts @@ -37,7 +37,7 @@ import { type NativeSnapshotContext, type NativeSnapshotRuntime, } from "../../runtime/context.js"; -import type { FamilyCapture } from "../../runtime/types.js"; +import type { FamilySettingsView } from "../../runtime/session-settings.js"; import { resolveWebRuntimeConfig, warnIfSecretBearingProjectConfigTracked } from "./config.js"; import { createWebFetchTool } from "./fetch-service.js"; import { @@ -160,7 +160,7 @@ export function createWebToolsLog(options?: { } function diagnosticsFor( - capture: FamilyCapture, + view: FamilySettingsView, resolved: ReturnType, log: (event: WebToolsDiagnostic) => void, ): void { @@ -168,7 +168,7 @@ function diagnosticsFor( level: "info", message: "web tools configuration loaded", extra: { - familyId: capture.familyId, + familyId: view.familyId, searchProvider: resolved.search.provider, ...(resolved.search.provider === "zai" ? { searchRegion: resolved.search.region } : {}), searchCredentialSource: resolved.search.credential?.source ?? "missing", @@ -186,42 +186,32 @@ function diagnosticsFor( } // START_BLOCK_POLICY -/** Resolve the bound-family web-tools policy; never falls back to current/default config. */ +/** Resolve the family-settings web-tools policy for one session. */ async function resolvePolicy( runtime: NativeSnapshotRuntime, sessionID: string, log: (event: WebToolsDiagnostic) => void, ): Promise { - const read = async (): Promise => { - try { - return await runtime.snapshots.configFor(sessionID); - } catch { - return undefined; - } - }; - let capture = await read(); - if (capture === undefined) { - try { - await runtime.snapshots.accept({ sessionID }); - } catch { - // fall through to the second read; absence is unknown policy below - } - capture = await read(); + let view: FamilySettingsView | undefined; + try { + view = await runtime.settings.configFor(sessionID); + } catch { + return undefined; } - if (capture === undefined) return undefined; - if (!isVvocPluginEnabled(capture.vvoc, "web-tools")) return "disabled"; + if (view === undefined) return undefined; + if (!isVvocPluginEnabled(view.vvoc, "web-tools")) return "disabled"; let resolved: ReturnType; try { resolved = resolveWebRuntimeConfig({ - config: capture.vvoc, + config: view.vvoc, source: { kind: "project" }, warnings: [], }); } catch { - // A corrupt captured config is unknown policy, never silently disabled. + // A corrupt config is unknown policy, never silently disabled. return undefined; } - diagnosticsFor(capture, resolved, log); + diagnosticsFor(view, resolved, log); return { resolved, permission: runtime.permissions }; } // END_BLOCK_POLICY diff --git a/src/plugins/workflow.contracts.integration.test.ts b/src/plugins/workflow.contracts.integration.test.ts index cdb98300..63ef97c0 100644 --- a/src/plugins/workflow.contracts.integration.test.ts +++ b/src/plugins/workflow.contracts.integration.test.ts @@ -128,9 +128,8 @@ async function createContractsHarness(): Promise { }; const loaded = await loadVvocConfig({ cwd: "/tmp/project" }); const fakeRuntime = { - snapshots: { + settings: { configFor: async () => ({ vvoc: loaded.config }), - accept: async () => ({ status: "unbound" }), }, client: async () => fakeClient, effectiveConfig: () => ({ vvoc: loaded.config }), diff --git a/src/plugins/workflow.delegated.integration.test.ts b/src/plugins/workflow.delegated.integration.test.ts index c969546c..8109ba33 100644 --- a/src/plugins/workflow.delegated.integration.test.ts +++ b/src/plugins/workflow.delegated.integration.test.ts @@ -599,9 +599,8 @@ async function createDelegatedPluginHarness( const loaded = await loadVvocConfig({ cwd: workspaceRoot }); const defaultCapture = { vvoc: loaded.config }; const fakeRuntime = { - snapshots: { + settings: { configFor: async () => defaultCapture, - accept: async () => ({ status: "unbound" }), }, client: async () => { if (harness.clientAcquireFails) { diff --git a/src/plugins/workflow.integration.test.ts b/src/plugins/workflow.integration.test.ts index 133cbfdd..15e8be0c 100644 --- a/src/plugins/workflow.integration.test.ts +++ b/src/plugins/workflow.integration.test.ts @@ -1682,11 +1682,10 @@ async function createWorkflowPluginHarness( const captures = options?.captures ?? {}; const defaultCapture = { vvoc: loaded.config }; const fakeRuntime = { - snapshots: { + settings: { // Tests explicitly bind a fixture policy per session; absence is only // used by the disabled/unbound regression and fails closed in production. configFor: async (sessionID: string) => captures[sessionID] ?? defaultCapture, - accept: async () => ({ status: "unbound" }), }, client: async () => fakeClient, effectiveConfig: () => ({ vvoc: loaded.config }), @@ -2440,12 +2439,12 @@ describe("workflow plugin integration", () => { expect(systemText).not.toContain("Selective delegation is available"); }); - test("a bound family keeps its captured profile while new work sees the changed policy", async () => { + test("a family with recorded settings keeps its profile while new work sees the changed policy", async () => { resetVvocConfigForTests(); - const captured = createDefaultVvocConfig(); - captured.orchestration = { profile: "orchestrated" }; + const recorded = createDefaultVvocConfig(); + recorded.orchestration = { profile: "orchestrated" }; const { plugin } = await createWorkflowPluginHarness("single-session", { - captures: { "session-bound": { vvoc: captured } }, + captures: { "session-bound": { vvoc: recorded } }, }); const bound = { diff --git a/src/plugins/workflow/index.ts b/src/plugins/workflow/index.ts index 7885c6b1..96b5644c 100644 --- a/src/plugins/workflow/index.ts +++ b/src/plugins/workflow/index.ts @@ -109,7 +109,8 @@ import { type NativeSessionInfoLike, type NativeSnapshotContext, } from "../../runtime/context.js"; -import type { EffectiveRuntimeConfig, SnapshotService } from "../../runtime/types.js"; +import type { EffectiveRuntimeConfig } from "../../runtime/types.js"; +import type { FamilySettingsService } from "../../runtime/session-settings.js"; import { createConsoleDiagnosticSink, decodeNativeSubagentResult, @@ -450,7 +451,7 @@ export interface WorkflowClient { * `NativeSnapshotRuntime` satisfies it structurally; tests inject a fake. */ export interface WorkflowRuntime { - readonly snapshots: Pick; + readonly settings: Pick; client(): Promise; effectiveConfig(): EffectiveRuntimeConfig; release(): Promise; @@ -504,30 +505,16 @@ async function setupWorkflow( async function resolveFamilyPolicy( sessionId: string, ): Promise<{ policy: ResolvedOrchestrationPolicy; enabled: boolean } | undefined> { - let capture: { vvoc: EffectiveRuntimeConfig["vvoc"] } | undefined; + let view: { vvoc: EffectiveRuntimeConfig["vvoc"] } | undefined; try { - capture = await runtime.snapshots.configFor(sessionId); + view = await runtime.settings.configFor(sessionId); } catch { return undefined; } - if (capture === undefined) { - // A first accepted workload may be persisted but not yet published; - // reconcile through the native acceptance service, then read again. - try { - await runtime.snapshots.accept({ sessionID: sessionId }); - } catch { - // fall through to the second read; absence fails closed below - } - try { - capture = await runtime.snapshots.configFor(sessionId); - } catch { - return undefined; - } - } - if (capture === undefined) return undefined; + if (view === undefined) return undefined; return { - policy: resolveOrchestrationPolicy(capture.vvoc), - enabled: isVvocPluginEnabled(capture.vvoc, "workflow"), + policy: resolveOrchestrationPolicy(view.vvoc), + enabled: isVvocPluginEnabled(view.vvoc, "workflow"), }; } async function requireFamilyEnabled(sessionId: string): Promise { @@ -536,7 +523,7 @@ async function setupWorkflow( throw new WorkflowDiagnosticError( "FAMILY_UNBOUND", "authorization", - `FAMILY_UNBOUND: no immutable policy capture is bound to session ${sessionId}; workflow tools fail closed until a family is bound.`, + `FAMILY_UNBOUND: no family settings could be resolved for session ${sessionId}; workflow tools fail closed until family settings resolve.`, ); } if (!family.enabled) { diff --git a/src/runtime/auxiliary.test.ts b/src/runtime/auxiliary.test.ts index 9ac0e21b..93e30c12 100644 --- a/src/runtime/auxiliary.test.ts +++ b/src/runtime/auxiliary.test.ts @@ -1,9 +1,9 @@ // FILE: src/runtime/auxiliary.test.ts -// VERSION: 2.0.0 +// VERSION: 3.0.0 // START_MODULE_CONTRACT -// PURPOSE: Verify snapshot-bound auxiliary title/generation behavior, verified-lineage child creation, retained title content, recursion protection, and fail-closed unbound refusal. -// SCOPE: In-memory auxiliary session double assertions for title short-circuit, bound model selection, retained message content, memoized children, and recursion guard; no native host. -// DEPENDS: [bun:test, src/runtime/auxiliary.ts, src/runtime/types.ts] +// PURPOSE: Verify settings-bound auxiliary title/generation behavior, family-rooted child creation, retained title content, recursion protection, and fail-closed unresolvable-settings refusal. +// SCOPE: In-memory auxiliary session double assertions for title short-circuit, resolved model selection, retained message content, memoized children, and recursion guard; no native host. +// DEPENDS: [bun:test, src/runtime/auxiliary.ts, src/runtime/session-settings.js, src/runtime/types.js, src/lib/vvoc-config.js] // LINKS: [M-NATIVE-RUNTIME, V-M-NATIVE-RUNTIME] // ROLE: TEST // MAP_MODE: LOCALS @@ -11,24 +11,19 @@ // // START_MODULE_MAP // FakeAuxiliarySession - In-memory AuxiliarySessionApi double. -// makeCapture - Build a bound family capture fixture. -// build - Wire the auxiliary service against the fake session and a capture. +// makeView - Build a resolved family settings view fixture. +// build - Wire the auxiliary service against the fake session and a view. // END_MODULE_MAP // // START_CHANGE_SUMMARY -// LAST_CHANGE: [C-OPENCODE-V2-NATIVE T-002 - Verified lineage children and retained title content coverage.] +// LAST_CHANGE: [C-SESSION-OWNED-SETTINGS T-002 - Rewired the suite onto the family settings view; qualification and snapshot variants are gone.] // END_CHANGE_SUMMARY import { describe, expect, test } from "bun:test"; -import { createDefaultVvocConfig } from "../lib/vvoc-config.js"; -import { createAuxiliaryService } from "./auxiliary.js"; -import { - SnapshotUnboundError, - type AuxiliaryService, - type AuxiliarySessionApi, - type FamilyCapture, - type ModelSelection, -} from "./types.js"; +import { createDefaultVvocConfig, type VvocConfig } from "../lib/vvoc-config.js"; +import { createAuxiliaryService, primarySettingsSelection } from "./auxiliary.js"; +import { SessionLineageError, type FamilySettingsView } from "./session-settings.js"; +import type { AuxiliaryService, AuxiliarySessionApi, ModelSelection } from "./types.js"; class FakeAuxiliarySession implements AuxiliarySessionApi { readonly creates: Array<{ @@ -66,59 +61,80 @@ class FakeAuxiliarySession implements AuxiliarySessionApi { } } -function makeCapture(familyId = "ses_root"): FamilyCapture { +function makeView( + familyId = "ses_root", + roles: Record = { default: "prov/m1" }, + roleModels: Record = { + default: { providerID: "prov", modelID: "m1" }, + }, +): FamilySettingsView { return { - schemaVersion: 1, - snapshotId: "snap", - integrity: "integrity", familyId, - capturedAt: 1, - location: { directory: "/project", projectID: "proj", canonical: "/project" }, - roles: { default: "prov/m1" }, - roleModels: { default: { providerID: "prov", modelID: "m1" } }, - agents: [], - variants: [ - { id: "snap.m1", providerID: "prov", modelID: "m1", body: { thinking: { type: "enabled" } } }, - ], - modelSettings: [], - vvoc: createDefaultVvocConfig(), - intent: { mode: "implicit", source: "config" }, + locationDirectory: "/project", + rootModel: { providerID: "prov", modelID: "m-native" }, + settings: { + version: 1, + roles, + profile: "orchestrated", + plugins: {}, + }, + vvoc: createDefaultVvocConfig() as VvocConfig, + roleModels, + source: "metadata", + settingsId: "settings0", }; } function build( session: FakeAuxiliarySession, - capture: FamilyCapture | undefined, + view: FamilySettingsView | undefined, ): AuxiliaryService { return createAuxiliaryService({ - familyOf: async () => capture?.familyId ?? "ses_unbound", - policy: async () => capture, + familyOf: async () => view?.familyId ?? "ses_unresolved", + policy: async () => view, session, + fallbackDirectory: "/runtime", }); } +describe("primarySettingsSelection", () => { + test("prefers the default role, then the first role, then the root model", () => { + expect(primarySettingsSelection(makeView())?.modelID).toBe("m1"); + const noDefault = makeView( + "ses_root", + { fast: "prov/m2" }, + { + fast: { providerID: "prov", modelID: "m2" }, + }, + ); + expect(primarySettingsSelection(noDefault)?.modelID).toBe("m2"); + const empty = makeView("ses_root", {}, {}); + expect(primarySettingsSelection(empty)?.modelID).toBe("m-native"); + }); +}); + describe("createAuxiliaryService", () => { test("a host-supplied title result short-circuits generation", async () => { const session = new FakeAuxiliarySession(); - const service = build(session, makeCapture()); + const service = build(session, makeView()); expect(await service.title({ sessionID: "ses_root", result: "Host title" })).toBe("Host title"); expect(session.creates).toHaveLength(0); expect(session.generates).toHaveLength(0); }); - test("an unbound family refuses auxiliary work without creating a session", async () => { + test("unresolvable settings refuse auxiliary work without creating a session", async () => { const session = new FakeAuxiliarySession(); const service = build(session, undefined); - expect(await service.title({ sessionID: "ses_unbound" })).toBeUndefined(); + expect(await service.title({ sessionID: "ses_unresolved" })).toBeUndefined(); await expect( - service.generate({ sessionID: "ses_unbound", kind: "generate", prompt: "x" }), - ).rejects.toBeInstanceOf(SnapshotUnboundError); + service.generate({ sessionID: "ses_unresolved", kind: "generate", prompt: "x" }), + ).rejects.toBeInstanceOf(SessionLineageError); expect(session.creates).toHaveLength(0); }); - test("bound title work creates a lineage child, activates the qualified variant, and retains messages", async () => { + test("bound title work creates a family-rooted child with the resolved role model and retains messages", async () => { const session = new FakeAuxiliarySession(); - const service = build(session, makeCapture()); + const service = build(session, makeView()); expect( await service.title({ @@ -136,20 +152,44 @@ describe("createAuxiliaryService", () => { locationDirectory: "/project", title: "vvoc title", kind: "title", - model: { providerID: "prov", modelID: "m1", variant: "snap.m1" }, + model: { providerID: "prov", modelID: "m1" }, }, ]); expect(session.switches).toEqual([ - { sessionID: "aux-1", model: { providerID: "prov", modelID: "m1", variant: "snap.m1" } }, + { sessionID: "aux-1", model: { providerID: "prov", modelID: "m1" } }, ]); // Retained title content is sent, never an empty prompt. expect(session.generates[0]?.prompt).toContain("fix the parser bug"); expect(session.generates[0]?.prompt).toContain("on it"); }); - test("reuses the bound child across kinds and guards re-entrant titles", async () => { + test("a named role resolves its own selection, not the default", async () => { + const session = new FakeAuxiliarySession(); + const view = makeView( + "ses_root", + { default: "prov/m1", fast: "prov/m-fast" }, + { + default: { providerID: "prov", modelID: "m1" }, + fast: { providerID: "prov", modelID: "m-fast" }, + }, + ); + const service = build(session, view); + expect( + await service.generate({ + sessionID: "ses_root", + kind: "generate", + prompt: "x", + role: "fast", + }), + ).toEqual({ + text: "text:aux-1", + }); + expect(session.creates[0]?.model).toEqual({ providerID: "prov", modelID: "m-fast" }); + }); + + test("reuses the child across kinds and guards re-entrant titles", async () => { const session = new FakeAuxiliarySession(); - const service = build(session, makeCapture()); + const service = build(session, makeView()); let reentrant: string | undefined; session.onGenerate = () => { void service.title({ sessionID: "ses_root" }).then((value) => { diff --git a/src/runtime/auxiliary.ts b/src/runtime/auxiliary.ts index f8c1c04e..edeb662b 100644 --- a/src/runtime/auxiliary.ts +++ b/src/runtime/auxiliary.ts @@ -1,37 +1,39 @@ // FILE: src/runtime/auxiliary.ts -// VERSION: 2.0.0 +// VERSION: 3.0.0 // START_MODULE_CONTRACT -// PURPOSE: Provide snapshot-bound auxiliary generation for titles and Guardian inference through an explicitly bound native child session and native session.generate. -// SCOPE: Family-bound auxiliary child creation (explicit agent/model binding, not a fork that copies the parent agent), awaited snapshot-qualified switchModel, title-result short-circuit, retention of native title message content, recursion/deadlock guard, and fail-closed refusal when no family policy is bound. Never calls the stateless experimental generate API and never invents a model. -// DEPENDS: [src/runtime/model-registry.ts, src/runtime/types.ts] +// PURPOSE: Provide family-settings-bound auxiliary generation for titles and Guardian inference through an explicitly created native child session and native session.generate. +// SCOPE: Auxiliary child creation parented to the family root (explicit role model from the family settings, recorded workload metadata, not a fork that copies the parent agent), awaited model pinning per use, title-result short-circuit, retention of native title message content, recursion/deadlock guard, and fail-closed refusal when no family settings can be resolved. Never calls the stateless experimental generate API and never invents a model. +// DEPENDS: [src/runtime/session-settings.ts, src/runtime/types.ts] // LINKS: [M-NATIVE-RUNTIME, V-M-NATIVE-RUNTIME] // ROLE: RUNTIME // MAP_MODE: EXPORTS // END_MODULE_CONTRACT // // START_MODULE_MAP -// AuxiliaryServiceDeps - Injectable native boundaries for snapshot-bound auxiliary generation. -// createAuxiliaryService - Build the snapshot-bound auxiliary service consumed by title and Guardian. +// AuxiliaryServiceDeps - Injectable native boundaries for settings-bound auxiliary generation. +// primarySettingsSelection - Select the family's default model for auxiliary work. +// createAuxiliaryService - Build the settings-bound auxiliary service consumed by title and Guardian. // END_MODULE_MAP // // START_CHANGE_SUMMARY -// LAST_CHANGE: [C-OPENCODE-V2-NATIVE T-002 attempt 5 - Auxiliary children are created by the runtime as real imported parented sessions carrying host-owned workload metadata; this service records kind/role for that import.] +// LAST_CHANGE: [C-SESSION-OWNED-SETTINGS T-002 - Auxiliary children now bind through the family settings view: role selections come from root metadata, qualification and snapshot variants are gone, and the child model is the plain resolved selection.] // END_CHANGE_SUMMARY -import { primarySelection, qualifySelection } from "./model-registry.js"; -import { - SnapshotUnboundError, - type AuxiliaryMessageContent, - type AuxiliaryService, - type AuxiliarySessionApi, - type FamilyCapture, +import { SessionLineageError, type FamilySettingsView } from "./session-settings.js"; +import type { + AuxiliaryMessageContent, + AuxiliaryService, + AuxiliarySessionApi, + ModelSelection, } from "./types.js"; -/** Injectable native boundaries for snapshot-bound auxiliary generation. */ +/** Injectable native boundaries for settings-bound auxiliary generation. */ export interface AuxiliaryServiceDeps { familyOf(sessionID: string): Promise; - policy(sessionID: string): Promise; + policy(sessionID: string): Promise; readonly session: AuxiliarySessionApi; + /** Runtime location directory used when the family root exposes none. */ + readonly fallbackDirectory: string; } const TITLE_INSTRUCTION = @@ -47,29 +49,36 @@ function titlePrompt(messages: ReadonlyArray | undefine return `${transcript}\n\n${TITLE_INSTRUCTION}`; } +/** Select the family's default model for auxiliary work: default role, first role, then the root's native model. */ +export function primarySettingsSelection(view: FamilySettingsView): ModelSelection | undefined { + const defaultRole = view.roleModels.default; + if (defaultRole !== undefined) return defaultRole; + for (const selection of Object.values(view.roleModels)) return selection; + return view.rootModel; +} + /** - * Build the snapshot-bound auxiliary service. Bound work runs on a verified - * lineage child of the family root whose model is the family's captured, - * family-qualified selection; unbound work refuses to fabricate a model. + * Build the settings-bound auxiliary service. Bound work runs on a verified + * lineage child of the family root whose model is the family's resolved role + * selection; unresolvable settings refuse to fabricate a model. */ export function createAuxiliaryService(deps: AuxiliaryServiceDeps): AuxiliaryService { const children = new Map(); const active = new Set(); async function boundChild( - capture: FamilyCapture, + view: FamilySettingsView, kind: "title" | "compaction" | "generate", role: string | undefined, ): Promise { - const base = role === undefined ? primarySelection(capture) : capture.roleModels[role]; - if (base === undefined) return undefined; - const selection = qualifySelection(capture, base); - const cacheKey = `${capture.familyId}\u0000${kind}`; + const selection = role === undefined ? primarySettingsSelection(view) : view.roleModels[role]; + if (selection === undefined) return undefined; + const cacheKey = `${view.familyId}\u0000${kind}`; let child = children.get(cacheKey); if (child === undefined) { const created = await deps.session.create({ - parentID: capture.familyId, - locationDirectory: capture.location.directory, + parentID: view.familyId, + locationDirectory: view.locationDirectory ?? deps.fallbackDirectory, title: `vvoc ${kind}`, kind, ...(role === undefined ? {} : { role }), @@ -83,12 +92,12 @@ export function createAuxiliaryService(deps: AuxiliaryServiceDeps): AuxiliarySer } async function generateBound( - capture: FamilyCapture, + view: FamilySettingsView, kind: "title" | "compaction" | "generate", prompt: string, role: string | undefined, ): Promise<{ text: string } | undefined> { - const child = await boundChild(capture, kind, role); + const child = await boundChild(view, kind, role); if (child === undefined) return undefined; return deps.session.generate({ sessionID: child, prompt }); } @@ -97,38 +106,38 @@ export function createAuxiliaryService(deps: AuxiliaryServiceDeps): AuxiliarySer async title(event) { // A host-supplied title always wins; the plugin never overrides it. if (typeof event.result === "string" && event.result.length > 0) return event.result; - const capture = await deps.policy(event.sessionID); - if (capture === undefined) return undefined; + const view = await deps.policy(event.sessionID); + if (view === undefined) return undefined; // Guard the whole family against a bound generate recursively re-entering title work. - if (active.has(capture.familyId)) return undefined; - active.add(capture.familyId); + if (active.has(view.familyId)) return undefined; + active.add(view.familyId); try { const generated = await generateBound( - capture, + view, "title", titlePrompt(event.messages), event.role, ); return generated?.text; } finally { - active.delete(capture.familyId); + active.delete(view.familyId); } }, async generate(input) { - const capture = await deps.policy(input.sessionID); - if (capture === undefined) { - throw new SnapshotUnboundError( - "Auxiliary generation requires a bound family snapshot; refusing stateless generation.", + const view = await deps.policy(input.sessionID); + if (view === undefined) { + throw new SessionLineageError( + "Auxiliary generation requires resolvable family settings; refusing stateless generation.", ); } - if (active.has(capture.familyId)) { - throw new SnapshotUnboundError("Auxiliary generation is already running for this family."); + if (active.has(view.familyId)) { + throw new SessionLineageError("Auxiliary generation is already running for this family."); } - active.add(capture.familyId); + active.add(view.familyId); try { - return await generateBound(capture, input.kind, input.prompt, input.role); + return await generateBound(view, input.kind, input.prompt, input.role); } finally { - active.delete(capture.familyId); + active.delete(view.familyId); } }, }; diff --git a/src/runtime/context-inspection.test.ts b/src/runtime/context-inspection.test.ts index 3c39c93d..1aae94d2 100644 --- a/src/runtime/context-inspection.test.ts +++ b/src/runtime/context-inspection.test.ts @@ -108,17 +108,23 @@ const SAFE_PLUGINS = { function baseCapture(overrides: Record = {}): Record { return { - schemaVersion: 1, familyId: "fam_1", - snapshotId: "snap_1", - integrity: "sha256:deadbeef", - capturedAt: 42, - location: { directory: "/moved/worktree", projectID: "proj_1", workspaceID: "ws_1" }, + locationDirectory: "/moved/worktree", + settings: { + version: 1, + roles: { default: "secret-role" }, + profile: "balanced", + plugins: {}, + }, vvoc: { plugins: SAFE_PLUGINS, roles: { default: "secret-role" }, secretsRedaction: { secret: "top-secret" }, }, + roleModels: { default: { providerID: "p", modelID: "m" } }, + source: "metadata", + settingsId: "snap_1", + // Legacy raw-capture noise: must never surface in the projected result. modelSettings: [{ providerID: "p", modelID: "m", body: { secret: "leak" } }], rawIntent: { model: "p/m" }, ...overrides, @@ -380,7 +386,7 @@ describe("context inspection handler", () => { status: "available", scope: "family", contextEnabled: true, - provenance: { familyId: "fam_1", snapshotId: "snap_1", capturedAt: 42 }, + provenance: { familyId: "fam_1", snapshotId: "snap_1", capturedAt: 1_700_000_000_000 }, }); const serialized = JSON.stringify(result); expect(serialized).not.toContain("top-secret"); diff --git a/src/runtime/context-inspection.ts b/src/runtime/context-inspection.ts index 018d7a97..316523f3 100644 --- a/src/runtime/context-inspection.ts +++ b/src/runtime/context-inspection.ts @@ -2,7 +2,7 @@ // VERSION: 2.0.0 // START_MODULE_CONTRACT // PURPOSE: Register the read-only native context-inspection RPC on the server plugin context, projecting the registered tool catalog and an allowlisted session-family or current-runtime policy for the TUI. -// SCOPE: Structural server context/runtime seam, bounded catalog reads via ctx.tool.list with server-side vendor schema conversion (Schema-owned Effect 4 resolved from @opencode/schema), serving-location/project session validation via ctx.session.get, meaningful FamilyCapture validation via snapshots.policy, current-runtime preview from the already-loaded effective config, value-free stable error codes, and strict allowlisted output construction. It never stages/accepts work, switches models, reloads, executes tools, or connects MCP servers. +// SCOPE: Structural server context/runtime seam, bounded catalog reads via ctx.tool.list with server-side vendor schema conversion (Schema-owned Effect 4 resolved from @opencode/schema), serving-location/project session validation via ctx.session.get, meaningful FamilySettingsView validation via settings.configFor, current-runtime preview from the already-loaded effective config, value-free stable error codes, and strict allowlisted output construction. It never stages/accepts work, switches models, reloads, executes tools, or connects MCP servers. // DEPENDS: [node:module, node:url, @opencode/schema/tool, src/runtime/context-inspection-contract.ts, src/runtime/types.ts] // LINKS: [M-NATIVE-RUNTIME, M-PLUGIN-CONTEXT-TUI, DF-CONTEXT-INSPECTION, V-M-NATIVE-RUNTIME, V-M-PLUGIN-CONTEXT-TUI] // ROLE: RUNTIME @@ -11,15 +11,16 @@ // // START_MODULE_MAP // ContextInspectionServerContext - Structural native server plugin context accepted by the registration helper. -// ContextInspectionServerRuntime - Structural runtime snapshot/config seam the handler may read. +// ContextInspectionServerRuntime - Structural runtime settings/config seam the handler may read. // createContextInspectionHandler - Build the production inspection handler for focused tests. // registerContextInspectionRpc - Register the versioned read-only inspection RPC and return its registration handle. -// isMeaningfulFamilyCapture - Validate a durable capture before exposing any allowlisted policy. +// isMeaningfulFamilySettingsView - Validate a resolved family settings view before exposing any allowlisted policy. // loadSchemaOwnedVendorConverter - Resolve the Schema-owned Effect 4 codec converter for known built-in tool schemas. // END_MODULE_MAP // // START_CHANGE_SUMMARY -// LAST_CHANGE: [C-OPENCODE-V2-NATIVE T-008 attempt 2 - Added value-free error codes, meaningful-capture validation, current-location session checks, undefined-preview rejection, bounded schedule reporting, and Schema-owned Effect schema conversion.] +// LAST_CHANGE: [C-SESSION-OWNED-SETTINGS T-003 - The family policy source is now the resolved session-owned settings view; provenance carries the settings content id and observation time, and validation matches the view shape.] +// PREVIOUS: [C-OPENCODE-V2-NATIVE T-008 attempt 2 - Added value-free error codes, meaningful-capture validation, current-location session checks, undefined-preview rejection, bounded schedule reporting, and Schema-owned Effect schema conversion.] // END_CHANGE_SUMMARY import { createRequire } from "node:module"; @@ -59,9 +60,9 @@ export interface ContextInspectionServerContext { } /** - * Structural runtime snapshot/config seam the handler may read. `policy` is the - * session-family capture lookup; `currentConfig` is the already-loaded effective - * config used only for a labelled current-runtime preview. + * Structural runtime settings/config seam the handler may read. `policy` is the + * session-family settings resolution; `currentConfig` is the already-loaded + * effective config used only for a labelled current-runtime preview. */ export interface ContextInspectionServerRuntime { policy(sessionID: string): Promise; @@ -91,7 +92,7 @@ export function createContextInspectionHandler( const warnings: string[] = []; const catalog = input.includeCatalog === false ? undefined : await readCatalog(ctx, warnings); - const policy = await readPolicy(ctx, runtime, input.sessionID, location, warnings); + const policy = await readPolicy(ctx, runtime, input.sessionID, location, observedAt, warnings); return { version: CONTEXT_INSPECTION_VERSION, @@ -231,6 +232,7 @@ async function readPolicy( runtime: ContextInspectionServerRuntime, sessionID: string | undefined, location: ContextInspectionLocation, + observedAt: number, warnings: string[], ): Promise { if (sessionID === undefined || sessionID.length === 0) { @@ -261,9 +263,9 @@ async function readPolicy( ); } - let capture: unknown; + let view: unknown; try { - capture = await runtime.policy(sessionID); + view = await runtime.policy(sessionID); } catch { return unavailablePolicy( "family", @@ -272,30 +274,31 @@ async function readPolicy( "policy lookup failed", ); } - if (capture === undefined) { + if (view === undefined) { return unavailablePolicy( "family", "policy_capture_missing", warnings, - "no captured family policy is bound to this session", + "no family settings could be resolved for this session", ); } - if (!isMeaningfulFamilyCapture(capture)) { + if (!isMeaningfulFamilySettingsView(view)) { return unavailablePolicy( "family", "policy_capture_invalid", warnings, - "the captured family policy is corrupt or incomplete", + "the resolved family settings view is corrupt or incomplete", ); } - const projected = projectContextInspectionPolicy(readPolicySource(capture)); + const record = view as Record; + const projected = projectContextInspectionPolicy(readPolicySource(record)); if (!projected.complete) { return unavailablePolicy( "family", "schedule_projection_exceeded", warnings, - "the captured peak schedule exceeds the bounded projection", + "the resolved peak schedule exceeds the bounded projection", ); } const policyWarnings = projected.warnings.length === 0 ? undefined : [...projected.warnings]; @@ -306,7 +309,7 @@ async function readPolicy( contextEnabled: projected.contextEnabled, analyticsEnabled: projected.analyticsEnabled, peakHours: projected.peakHours, - provenance: readProvenance(capture, location), + provenance: readProvenance(record, location, observedAt), ...(policyWarnings === undefined ? {} : { warnings: policyWarnings }), }; } @@ -360,20 +363,20 @@ function readPreviewPolicy( // START_BLOCK_CAPTURE_VALIDATION /** - * Validate a durable family capture before exposing any allowlisted policy. An - * arbitrary object (for example `{}`) is never treated as available defaults. + * Validate a resolved family settings view before exposing any allowlisted + * policy. An arbitrary object (for example `{}`) is never treated as available + * defaults. */ -export function isMeaningfulFamilyCapture(value: unknown): value is Record { +export function isMeaningfulFamilySettingsView(value: unknown): value is Record { if (!isRecord(value)) return false; - if (value.schemaVersion !== 1) return false; if (!nonEmptyString(value.familyId)) return false; - if (!nonEmptyString(value.snapshotId)) return false; - if (!nonEmptyString(value.integrity)) return false; - if (typeof value.capturedAt !== "number" || !Number.isFinite(value.capturedAt)) return false; + if (!isRecord(value.settings)) return false; + if (value.settings.version !== 1) return false; + if (!isRecord(value.settings.roles) || !isRecord(value.settings.plugins)) return false; + if (typeof value.settings.profile !== "string" || value.settings.profile.length === 0) { + return false; + } if (!isRecord(value.vvoc) || !isRecord(value.vvoc.plugins)) return false; - const location = value.location; - if (!isRecord(location) || !nonEmptyString(location.directory)) return false; - if (!nonEmptyString(location.projectID)) return false; return true; } @@ -400,25 +403,20 @@ function readPlugins(vvoc: Record): Readonly, + view: Record, fallback: ContextInspectionLocation, + observedAt: number, ): ContextInspectionProvenance { - const location = isRecord(capture.location) ? capture.location : undefined; - const directory = location === undefined ? undefined : readString(location.directory); - const projectID = location === undefined ? undefined : readString(location.projectID); - const workspaceID = location === undefined ? undefined : readString(location.workspaceID); + const directory = readString(view.locationDirectory); return { - familyId: readString(capture.familyId) ?? "", - snapshotId: readString(capture.snapshotId) ?? "", - capturedAt: typeof capture.capturedAt === "number" ? capture.capturedAt : 0, - location: - directory === undefined - ? fallback - : { - directory, - ...(projectID === undefined ? {} : { projectID }), - ...(workspaceID === undefined ? {} : { workspaceID }), - }, + familyId: readString(view.familyId) ?? "", + snapshotId: readString(view.settingsId) ?? "", + capturedAt: typeof observedAt === "number" ? observedAt : 0, + location: { + directory: directory ?? fallback.directory, + ...(fallback.projectID === undefined ? {} : { projectID: fallback.projectID }), + ...(fallback.workspaceID === undefined ? {} : { workspaceID: fallback.workspaceID }), + }, }; } diff --git a/src/runtime/context.test.ts b/src/runtime/context.test.ts index fd9f277f..619df655 100644 --- a/src/runtime/context.test.ts +++ b/src/runtime/context.test.ts @@ -1,9 +1,9 @@ // FILE: src/runtime/context.test.ts -// VERSION: 1.1.0 +// VERSION: 2.0.0 // START_MODULE_CONTRACT -// PURPOSE: Verify context-identity native runtime and family-binding-service acquisition, per-lease idempotent release, distinct-context isolation at one location, registration teardown/reacquisition, and disposed-state safety. -// SCOPE: Deterministic fixture-host assertions for the runtime and family-binding registries; no real host, network, or V1 client. -// DEPENDS: [bun:test, src/runtime/context.js, src/runtime/snapshots.js, src/runtime/types.js] +// PURPOSE: Verify context-identity native runtime acquisition, per-lease idempotent release, distinct-context isolation at one location, registration teardown/reacquisition, disposed-state safety, and agent-binding recomputation coherence. +// SCOPE: Deterministic fixture-host assertions for the runtime registry; no real host, network, or V1 client. +// DEPENDS: [bun:test, src/runtime/context.js, src/lib/vvoc-config.js, src/runtime/types.js] // LINKS: [M-NATIVE-RUNTIME, V-M-NATIVE-RUNTIME] // ROLE: TEST // MAP_MODE: LOCALS @@ -15,21 +15,17 @@ // NativeHandler - Shape of one registered native RPC handler. // makeLocation - Build a structural runtime location fixture. // FakeNativeHost - In-memory native host double that records discovery, registration, and client construction. -// MemoryBindingStore - In-memory binding-store double for family-binding sharing tests. -// createSnapshotDeps - Minimal injectable snapshot-service dependencies. // END_MODULE_MAP // // START_CHANGE_SUMMARY -// LAST_CHANGE: [C-SNAPSHOT-ANCHORING-REDESIGN T-004/T-005 - Updated runtime acquisition coverage for durable family-binding services and retired staged admission.] +// LAST_CHANGE: [C-SESSION-OWNED-SETTINGS T-002 - Dropped the snapshot-service registry coverage; settings-service sharing is covered by the model-roles integration suite.] +// PREVIOUS: [C-SNAPSHOT-ANCHORING-REDESIGN T-004/T-005 - Updated runtime acquisition coverage for durable family-binding services and retired staged admission.] // END_CHANGE_SUMMARY import { describe, expect, test } from "bun:test"; import type { Plugin } from "@opencode/plugin"; -import { acquireRuntime, acquireSnapshotService, rebuildAgentBindings } from "./context.js"; +import { acquireRuntime, rebuildAgentBindings } from "./context.js"; import { createDefaultVvocConfig } from "../lib/vvoc-config.js"; -import { behaviourContentHash, type BehaviourProjection } from "./snapshot-config.js"; -import type { SnapshotServiceDeps } from "./snapshots.js"; -import type { FamilyBindingRecord } from "./snapshot-store.js"; import type { EffectiveRuntimeConfig } from "./types.js"; import { RuntimeDisposedError, @@ -255,120 +251,6 @@ describe("acquireRuntime", () => { }); }); -class MemoryBindingStore { - readonly snapshots = new Map(); - readonly bindings = new Map(); - - async writeSnapshot(projection: BehaviourProjection) { - const hash = behaviourContentHash(projection); - this.snapshots.set(hash, projection); - return hash; - } - async readSnapshot(hash: string) { - return this.snapshots.get(hash); - } - async writeBinding(binding: FamilyBindingRecord) { - this.bindings.set(binding.familyId, binding); - } - async readBinding(familyId: string) { - return this.bindings.get(familyId); - } - async removeBinding(familyId: string) { - this.bindings.delete(familyId); - } - async listBindings() { - return [...this.bindings.values()]; - } - async listSnapshots() { - return [...this.snapshots.keys()]; - } - async removeSnapshot(hash: string) { - this.snapshots.delete(hash); - } - async countBindings() { - const counts = new Map(); - for (const binding of this.bindings.values()) { - counts.set(binding.snapshotHash, (counts.get(binding.snapshotHash) ?? 0) + 1); - } - return counts; - } -} - -function createSnapshotDeps(store: MemoryBindingStore): SnapshotServiceDeps { - return { - store, - location: { - directory: "/workspace/shared", - projectID: "project-1", - canonical: "/workspace/shared", - }, - loadConfig: async () => ({ - roles: { default: "prov/m1" }, - agentRoles: { build: "default" }, - vvoc: createDefaultVvocConfig(), - }), - readSession: async (sessionID) => - sessionID === "ses_root" - ? { id: sessionID, locationDirectory: "/workspace/shared" } - : undefined, - readSessionModel: async () => undefined, - switchModel: async () => undefined, - auxiliarySession: { - create: async () => ({ sessionID: "ses_aux" }), - switchModel: async () => undefined, - generate: async () => ({ text: "x" }), - }, - }; -} - -describe("acquireSnapshotService", () => { - test("shares one snapshot service for the same context and dependency instance", async () => { - const host = new FakeNativeHost(); - const context = host.createContext(makeLocation("/workspace/shared")); - const deps = createSnapshotDeps(new MemoryBindingStore()); - - const first = acquireSnapshotService(context, deps); - const second = acquireSnapshotService(context, deps); - expect(second.snapshots).toBe(first.snapshots); - - // Releasing one lease twice must not dispose another consumer's live service. - await first.release(); - await first.release(); - expect(second.snapshots.familyOf("ses_root")).resolves.toBe("ses_root"); - await second.release(); - }); - - test("isolates distinct contexts that share one location", () => { - const host = new FakeNativeHost(); - const deps = createSnapshotDeps(new MemoryBindingStore()); - const location = makeLocation("/workspace/shared"); - const left = acquireSnapshotService(host.createContext(location), deps); - const right = acquireSnapshotService(host.createContext(location), deps); - - expect(right.snapshots).not.toBe(left.snapshots); - }); - - test("a released snapshot service rejects further admission", async () => { - const host = new FakeNativeHost(); - const lease = acquireSnapshotService( - host.createContext(makeLocation("/workspace/released")), - createSnapshotDeps(new MemoryBindingStore()), - ); - await lease.release(); - - const outcome = await lease.snapshots.admitOwned({ - sessionID: "ses_root", - directory: "/workspace/released", - location: { - directory: "/workspace/released", - projectID: "project-1", - canonical: "/workspace/released", - }, - }); - expect(outcome.status).toBe("rejected"); - }); -}); - describe("coherent admission bindings", () => { const vvoc = createDefaultVvocConfig(); diff --git a/src/runtime/context.ts b/src/runtime/context.ts index 7575f528..b1b4d430 100644 --- a/src/runtime/context.ts +++ b/src/runtime/context.ts @@ -1,9 +1,9 @@ // FILE: src/runtime/context.ts -// VERSION: 1.1.0 +// VERSION: 2.0.0 // START_MODULE_CONTRACT -// PURPOSE: Acquire, share by exact plugin-context identity, and release the lifecycle-managed native runtime, family-binding service, and centralized native runtime (client/permissions/bindings/config/model/auxiliary) consumed by every later native plugin. -// SCOPE: Context-identity reference-counted registries, per-acquisition idempotent leases, lazy authenticated-client caching, permission-service exposure, native model/agent overlay capture, default-model transform, config.updated reconfiguration, coherent first-work binding, rehydrated guard/variants, one-shot first-work admission tolerance for a dispatch resolved before the binding switch lands, session-deletion cleanup, cross-context coordination, the read-only context-inspection RPC, and idempotent teardown without stopping the host. No candidate staging, accepted-input publication, service discovery until a client is requested, location-only sharing, global configuration singleton, parallel fake runtime, or V1 compatibility facade. -// DEPENDS: [node:crypto, @opencode/plugin, src/lib/config-layers.ts, src/runtime/client.ts, src/runtime/coordination.ts, src/runtime/context-inspection.ts, src/runtime/model-registry.ts, src/runtime/permissions.ts, src/runtime/snapshot-config.ts, src/runtime/snapshot-store.ts, src/runtime/snapshots.ts, src/runtime/types.ts] +// PURPOSE: Acquire, share by exact plugin-context identity, and release the lifecycle-managed native runtime and the centralized native runtime (client/permissions/settings/config/model/auxiliary) consumed by every later native plugin, resolving session-owned family settings from the family root's metadata. +// SCOPE: Context-identity reference-counted registries, per-acquisition idempotent leases, lazy authenticated-client caching, permission-service exposure, native model/agent overlay capture, default-model transform, config.updated reconfiguration, lazy family-settings birth and resolution through host-verified lineage with read-modify-write metadata writes, one-shot creation reconciliation for host-created children, session-deletion cache hygiene, cross-context coordination, the read-only context-inspection RPC, and idempotent teardown without stopping the host. No candidate staging, no dispatch guard, no family snapshot store, no service discovery until a client is requested, no location-only sharing, no global configuration singleton, and no V1 compatibility facade. +// DEPENDS: [node:crypto, @opencode/plugin, src/lib/config-layers.ts, src/runtime/client.ts, src/runtime/coordination.ts, src/runtime/context-inspection.ts, src/runtime/model-registry.ts, src/runtime/permissions.ts, src/runtime/session-settings.ts, src/runtime/snapshot-config.ts, src/runtime/types.ts] // LINKS: [M-NATIVE-RUNTIME, V-M-NATIVE-RUNTIME] // ROLE: RUNTIME // MAP_MODE: EXPORTS @@ -11,21 +11,20 @@ // // START_MODULE_MAP // acquireRuntime - Return a per-acquisition release lease for the runtime shared by one exact plugin context. -// acquireSnapshotService - Return a per-acquisition release lease for the family-binding service shared by one exact plugin context and dependency instance. // NativeSessionInfoLike - Structural native session info used for lineage and provenance. -// NativeSnapshotHookEvents - Structural native hook event map used by the shared runtime. -// NativeSnapshotContext - Narrow structural native Plugin.Context used to acquire the shared snapshot runtime. +// NativeHookEvents - Structural native hook event map used by the shared runtime. +// NativeSnapshotContext - Narrow structural native Plugin.Context used to acquire the shared runtime. // NativeRegistration - Releasable native registration handle. // NativeForkClient - Structural full client for forks and parented imports. -// NativeSnapshotRuntimeOptions - Optional injectable native boundaries for the shared snapshot runtime. -// NativeSnapshotRuntime - Documented shared native runtime: client, permissions, family bindings, effective config, model reload, role override, first-work gateway and refresh. -// acquireNativeSnapshotRuntime - Acquire the shared native snapshot runtime for an actual Plugin.Context. +// NativeSnapshotRuntimeOptions - Optional injectable native boundaries for the shared runtime. +// NativeSnapshotRuntime - Documented shared native runtime: client, permissions, family settings, auxiliary work, effective config, role override, refresh. +// acquireNativeSnapshotRuntime - Acquire the shared native runtime for an actual Plugin.Context. // rebuildAgentBindings - Recompute agent bindings from a freshly read policy without trusting a cached vvoc-applied selection. // END_MODULE_MAP // // START_CHANGE_SUMMARY -// LAST_CHANGE: [Direct fix follow-up - The pump-missed user-choice capture in bindFor now ends the first-work tolerance too, keeping every explicit-choice path symmetric with the event pump so a stale window can never admit a later bare-base dispatch.] -// PREVIOUS: [Direct fix - First-work admission tolerance: the host may resolve a dispatch against the unqualified role base before the prompt hook's binding switch lands, so the guard now accepts that one bare-base resolution for a session this runtime just bound and switched, instead of killing the session's first turn with SnapshotAdmissionError.] +// LAST_CHANGE: [C-SESSION-OWNED-SETTINGS T-002 - Replaced the family-binding snapshot service and dispatch guard with session-owned settings: lazy root birth, lineage resolution, RMW metadata writes, one-shot creation reconciliation, and the legacy-binding migration.] +// PREVIOUS: [Direct fix follow-up - The pump-missed user-choice capture in bindFor now ends the first-work tolerance too, keeping every explicit-choice path symmetric with the event pump so a stale window can never admit a later bare-base dispatch.] // END_CHANGE_SUMMARY import { randomBytes } from "node:crypto"; @@ -39,42 +38,41 @@ import { ROLE_REFERENCE_PREFIX, } from "../lib/model-roles.js"; import { acquireNativeClient, nativeRuntimeDeps, type NativeClientAcquisition } from "./client.js"; -import { coordinateHost } from "./coordination.js"; -import { - applyAgentPolicies, - applyVariantRegistrations, - buildVariantRegistrations, - mergeModelBodyOverlay, - mergeModelHeadersOverlay, - primarySelection, - qualifySelection, -} from "./model-registry.js"; +import { coordinateHost, type HostCoordination } from "./coordination.js"; +import { applyAgentPolicies } from "./model-registry.js"; import { createPermissionService } from "./permissions.js"; import { canonicalModelVariant, - classifyInitialSelection, - decodeModelSelectedEvent, decodeSessionCreatedEvent, effectiveRuntimeConfig, isConfigUpdateEvent, - normalizeModelSelection, parseRoleSelections, } from "./snapshot-config.js"; import { registerContextInspectionRpc } from "./context-inspection.js"; -import { FileBindingStore } from "./snapshot-store.js"; -import { createSnapshotService, type SnapshotServiceDeps } from "./snapshots.js"; +import { + INTERNAL_PRIMARY_AGENTS, + isFamilyRootView, + mergeSettingsIntoMetadata, + migrateFamilyBindings, + fileLegacyBindingsReader, + projectFamilyConfig, + readSettingsFromMetadata, + resolveSessionFamily, + settingsFromConfig, + settingsId, + type FamilySettings, + type FamilySettingsService, + type FamilySettingsView, +} from "./session-settings.js"; +import { createAuxiliaryService } from "./auxiliary.js"; import { RuntimeDisposedError, - SnapshotAdmissionError, - SnapshotUnboundError, decodeAuxiliaryMetadata, decodeImportedSessionID, runtimeIdentity, - type AdmissionOutcome, type AgentEditorLike, type AgentPolicyBinding, type AuxiliaryService, - type CapturedModelSettings, type EffectiveRuntimeConfig, type ModelEditorLike, type ModelSelection, @@ -88,10 +86,6 @@ import { type RuntimeIdentity, type RuntimeLease, type RuntimeLocation, - type SnapshotAdmissionRequest, - type SnapshotLease, - type SnapshotService, - type VariantRegistration, } from "./types.js"; // START_BLOCK_RUNTIME_REGISTRY @@ -244,52 +238,7 @@ export function acquireRuntime(); - -/** - * Return a per-acquisition release lease for the snapshot service shared by one - * exact plugin context object and dependency instance. Distinct contexts never - * share a service, so per-location policies cannot leak across projects. - */ -export function acquireSnapshotService( - context: Context, - deps: SnapshotServiceDeps, -): SnapshotLease { - const key = `${objectId(contextIds, context)}:${objectId(depsIds, deps)}`; - let entry = snapshotServices.get(key); - if (entry === undefined) { - entry = { service: createSnapshotService(deps), refs: 0, disposed: false }; - snapshotServices.set(key, entry); - } - const owned = entry; - owned.refs += 1; - - let released = false; - return { - snapshots: owned.service, - release: async () => { - if (released) return; - released = true; - if (owned.disposed) return; - owned.refs -= 1; - if (owned.refs <= 0) { - owned.disposed = true; - snapshotServices.delete(key); - owned.service.dispose(); - } - }, - }; -} -// END_BLOCK_SNAPSHOT_REGISTRY - -// START_BLOCK_NATIVE_SNAPSHOT_RUNTIME +// START_BLOCK_NATIVE_RUNTIME /** Structural native session info used for lineage and provenance. */ export interface NativeSessionInfoLike { readonly id: string; @@ -310,21 +259,13 @@ export interface NativeSessionInfoLike { } /** Structural native hook events used by the shared runtime. */ -export interface NativeSnapshotHookEvents { - prompt: { readonly sessionID: string; readonly messageID?: string | undefined }; +export interface NativeHookEvents { context: { readonly sessionID: string; readonly agent?: string | undefined }; - generate: { readonly sessionID: string }; title: { readonly sessionID: string; result?: string | undefined; messages?: unknown }; - "model.request": { - readonly sessionID: string; - readonly model: unknown; - readonly agent?: string | undefined; - readonly kind: string; - }; } /** - * Narrow structural native `Plugin.Context` used to acquire the shared snapshot + * Narrow structural native `Plugin.Context` used to acquire the shared * runtime. A real `Plugin.Context` satisfies it, so the runtime is keyed by the * actual context object and later plugins share one instance. */ @@ -347,14 +288,22 @@ export interface NativeSnapshotContext extends RuntimeContext { readonly variant?: string; }; }): Promise; - update(input: { readonly sessionID: string; readonly title: string }): Promise; + /** + * Host-persisted session update. Metadata is REPLACE of the whole map, so + * callers must merge through the settings read-modify-write helper. + */ + update(input: { + readonly sessionID: string; + readonly title?: string | undefined; + readonly metadata?: Record | undefined; + }): Promise; generate(input: { readonly sessionID: string; readonly prompt: string; }): Promise<{ readonly text: string }>; - hook( + hook( name: Name, - callback: (event: NativeSnapshotHookEvents[Name]) => void | Promise, + callback: (event: NativeHookEvents[Name]) => void | Promise, ): Promise; }; readonly event: { @@ -399,10 +348,9 @@ export interface NativeForkClient extends RuntimeClient { }; } -/** Optional injectable native boundaries for the shared snapshot runtime (tests only). */ +/** Optional injectable native boundaries for the shared runtime (tests only). */ export interface NativeSnapshotRuntimeOptions { readonly runtimeDeps?: RuntimeDeps | undefined; - readonly store?: FileBindingStore | undefined; readonly now?: (() => number) | undefined; } @@ -410,28 +358,21 @@ export interface NativeSnapshotRuntimeOptions { /** Native runtime with lazy authenticated full client and resource permissions. */ readonly runtime: NativeRuntime; - /** Family-binding/config/model/auxiliary service. */ - readonly snapshots: SnapshotService; - /** - * Supported bind-on-first-work gateway for owned direct generate/synthetic work. - * It persists the credential-free family revision, materializes the qualified - * live overlay, and switches before native model resolution. Unbound raw - * external generate remains fail-closed. - */ - admitWorkload(request: SnapshotAdmissionRequest): Promise; - /** Snapshot-bound auxiliary generation. */ + /** Session-owned family settings service (lazy birth, lineage resolution, projections). */ + readonly settings: FamilySettingsService; + /** Family-settings-bound auxiliary generation (titles, compaction, Guardian). */ readonly auxiliary: AuxiliaryService; /** Lazily authenticated same-instance full native client (genuine OpenCodeClient by default). */ client(): Promise; /** Resource permission service bound to this runtime's client. */ readonly permissions: PermissionService; - /** Current effective vvoc configuration plus captured native overlays. */ + /** Current effective vvoc configuration (live, never family-frozen). */ effectiveConfig(): EffectiveRuntimeConfig; - /** Enable or disable role overriding (agent models and default model) without disabling capture/guard. */ + /** Enable or disable role overriding for newly created sessions without affecting resolution. */ setRoleOverride(enabled: boolean): Promise; - /** Reload rehydrated family bindings, variant registrations and native transforms. */ + /** Reload native transforms after role-override or config changes. */ refresh(): Promise; - /** Last config-watcher failure, surfaced instead of being silently swallowed. */ + /** Last contained runtime failure, surfaced instead of being silently swallowed. */ lastConfigError(): string | undefined; /** Idempotent per-lease release; disposes shared state only for the last lease. */ release(): Promise; @@ -451,11 +392,13 @@ function toSelection(ref: unknown): ModelSelection | undefined { if (typeof ref !== "object") return undefined; const record = ref as { id?: unknown; providerID?: unknown; variant?: unknown }; if (record.id === undefined || record.providerID === undefined) return undefined; - return normalizeModelSelection({ + const variant = + record.variant === undefined || record.variant === null ? undefined : String(record.variant); + return { providerID: String(record.providerID), modelID: String(record.id), - ...(record.variant === undefined ? {} : { variant: String(record.variant) }), - }); + ...(variant === undefined ? {} : { variant: canonicalModelVariant(variant) }), + }; } function sameModelSelection( @@ -482,79 +425,85 @@ function toRef(selection: ModelSelection): { }; } -function cloneOverlay( - value: Readonly> | undefined, -): Record | undefined { - return value === undefined ? undefined : { ...value }; +function readDefaultModel(editor: ModelEditorLike): ModelSelection | undefined { + const ref = editor.default.get(); + return ref === undefined ? undefined : { providerID: ref.providerID, modelID: ref.modelID }; } -/** True for a snapshot-qualified variant previously materialized by this runtime. */ -function isManagedVariantID( - variantID: string, - modelID: string, - ownVariantIds: ReadonlySet, -): boolean { - if (ownVariantIds.has(variantID)) return true; - const prefix = variantID.slice(0, 16); - const suffix = variantID.slice(16); - return ( - /^[0-9a-f]{16}$/.test(prefix) && (suffix === `.${modelID}` || suffix.startsWith(`.${modelID}.`)) - ); +function toSessionView(info: NativeSessionInfoLike): import("./types.js").NativeSessionView { + const model = toSelection(info.model); + const parentID = info.parentID === undefined ? undefined : String(info.parentID); + const forkSessionID = info.fork === undefined ? undefined : String(info.fork.sessionID); + const locationDirectory = + info.location === undefined ? undefined : String(info.location.directory); + const agent = info.agent === undefined ? undefined : String(info.agent); + const metadata = isRecord(info.metadata) ? info.metadata : undefined; + const hasActivity = + info.time?.idle !== undefined || + (info.tokens !== undefined && (info.tokens.input ?? 0) + (info.tokens.output ?? 0) > 0); + return { + id: info.id, + ...(parentID === undefined ? {} : { parentID }), + ...(forkSessionID === undefined ? {} : { forkSessionID }), + ...(locationDirectory === undefined ? {} : { locationDirectory }), + ...(agent === undefined ? {} : { agent }), + ...(metadata === undefined ? {} : { metadata }), + ...(model === undefined ? {} : { model }), + ...(hasActivity ? { hasActivity: true } : {}), + }; } -function wantedModelKeys(config: EffectiveRuntimeConfig): Set { - const keys = new Set(); - for (const selection of Object.values(parseRoleSelections(config.roles))) { - keys.add(`${selection.providerID}/${selection.modelID}`); - } - if (config.rootDefault !== undefined) { - keys.add(`${config.rootDefault.providerID}/${config.rootDefault.modelID}`); - } - if (config.nativeDefault !== undefined) { - keys.add(`${config.nativeDefault.providerID}/${config.nativeDefault.modelID}`); - } - // Custom/literal agent models outside the role map still need their overlay captured. - for (const binding of config.agentBindings ?? []) { - if (binding.selection !== undefined) { - keys.add(`${binding.selection.providerID}/${binding.selection.modelID}`); - } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +const PUMP_ERROR_MAX_CHARS = 500; +const PLACEHOLDER_TOKEN_RE = /__VVOC_SECRET_[A-Z_]+_[0-9a-f]{12}(?:_\d+)?__/g; + +/** + * Bounded, credential-safe diagnostic for one contained event-pump failure. The + * raw thrown value is never stored verbatim: placeholder tokens are redacted and + * the result is capped so an error carrying configuration text cannot leak or + * grow without bound. + */ +function describePumpError(error: unknown): string { + const raw = + error instanceof Error + ? `${error.name}: ${error.message}` + : typeof error === "string" + ? error + : "unknown error"; + const redacted = raw.replace(PLACEHOLDER_TOKEN_RE, "[redacted]"); + return redacted.length > PUMP_ERROR_MAX_CHARS + ? `${redacted.slice(0, PUMP_ERROR_MAX_CHARS)}…` + : redacted; +} + +function extractTitleMessages(messages: unknown): ReadonlyArray<{ role: string; text: string }> { + const extracted: Array<{ role: string; text: string }> = []; + if (!Array.isArray(messages)) return extracted; + for (const message of messages) { + if (!isRecord(message)) continue; + const role = typeof message.role === "string" ? message.role : "unknown"; + const text = extractMessageText(message.content); + if (text.length > 0) extracted.push({ role, text }); } - return keys; + return extracted; } -function captureModelSettings( - editor: ModelEditorLike, - wanted: ReadonlySet, - ownVariantIds: ReadonlySet, -): CapturedModelSettings[] { - const captured: CapturedModelSettings[] = []; - for (const model of editor.list()) { - const providerID = String(model.providerID); - // The wire/route id (`Model.Info.id`) is what sessions and requests reference, - // which differs from the catalog `modelID` for aliased models. - const modelID = String(model.id); - if (wanted.size > 0 && !wanted.has(`${providerID}/${modelID}`)) continue; - captured.push({ - providerID, - modelID, - ...(model.settings === undefined ? {} : { settings: cloneOverlay(model.settings) }), - ...(model.body === undefined ? {} : { body: cloneOverlay(model.body) }), - ...(model.headers === undefined ? {} : { headers: { ...model.headers } }), - }); - for (const variant of model.variants) { - // Never re-capture vvoc-generated variants as native overlays (no accumulation). - if (isManagedVariantID(String(variant.id), modelID, ownVariantIds)) continue; - captured.push({ - providerID, - modelID, - variant: String(variant.id), - ...(variant.settings === undefined ? {} : { settings: cloneOverlay(variant.settings) }), - ...(variant.body === undefined ? {} : { body: cloneOverlay(variant.body) }), - ...(variant.headers === undefined ? {} : { headers: { ...variant.headers } }), - }); +function extractMessageText(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + const parts: string[] = []; + for (const part of content) { + if (typeof part === "string") { + parts.push(part); + continue; } + if (!isRecord(part)) continue; + if (typeof part.text === "string") parts.push(part.text); } - return captured; + return parts.join("\n"); } /** Resolve one raw OpenCode model string (role reference or literal) to a selection. */ @@ -623,8 +572,8 @@ function captureAgentBindings( * selection that vvoc itself may have applied under a previous role set. A * vvoc-role-managed agent's selection is recomputed from the current role model, * raw OpenCode intent still wins, and an agent with no role keeps its genuine - * native literal. This is what makes `vvoc.json` role changes take effect on the - * first unbound workload even when the host emits no `config.updated`. + * native literal. This is what makes `vvoc.json` role changes take effect for + * newly created sessions even when the host emits no `config.updated`. */ export function rebuildAgentBindings( cached: ReadonlyArray, @@ -663,155 +612,9 @@ export function rebuildAgentBindings( } return [...result.values()]; } +// END_BLOCK_NATIVE_RUNTIME -function readDefaultModel(editor: ModelEditorLike): ModelSelection | undefined { - const ref = editor.default.get(); - return ref === undefined ? undefined : { providerID: ref.providerID, modelID: ref.modelID }; -} - -function toSessionView(info: NativeSessionInfoLike): import("./types.js").NativeSessionView { - const model = toSelection(info.model); - const parentID = info.parentID === undefined ? undefined : String(info.parentID); - const forkSessionID = info.fork === undefined ? undefined : String(info.fork.sessionID); - const locationDirectory = - info.location === undefined ? undefined : String(info.location.directory); - const agent = info.agent === undefined ? undefined : String(info.agent); - const metadata = isRecord(info.metadata) ? info.metadata : undefined; - const hasActivity = - info.time?.idle !== undefined || - (info.tokens !== undefined && (info.tokens.input ?? 0) + (info.tokens.output ?? 0) > 0); - return { - id: info.id, - ...(parentID === undefined ? {} : { parentID }), - ...(forkSessionID === undefined ? {} : { forkSessionID }), - ...(locationDirectory === undefined ? {} : { locationDirectory }), - ...(agent === undefined ? {} : { agent }), - ...(metadata === undefined ? {} : { metadata }), - ...(model === undefined ? {} : { model }), - ...(hasActivity ? { hasActivity: true } : {}), - }; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -const PUMP_ERROR_MAX_CHARS = 500; -const PLACEHOLDER_TOKEN_RE = /__VVOC_SECRET_[A-Z_]+_[0-9a-f]{12}(?:_\d+)?__/g; - -/** - * Bounded, credential-safe diagnostic for one contained event-pump failure. The - * raw thrown value is never stored verbatim: placeholder tokens are redacted and - * the result is capped so an error carrying configuration text cannot leak or - * grow without bound. - */ -function describePumpError(error: unknown): string { - const raw = - error instanceof Error - ? `${error.name}: ${error.message}` - : typeof error === "string" - ? error - : "unknown error"; - const redacted = raw.replace(PLACEHOLDER_TOKEN_RE, "[redacted]"); - return redacted.length > PUMP_ERROR_MAX_CHARS - ? `${redacted.slice(0, PUMP_ERROR_MAX_CHARS)}…` - : redacted; -} - -function extractTitleMessages(messages: unknown): ReadonlyArray<{ role: string; text: string }> { - const extracted: Array<{ role: string; text: string }> = []; - if (!Array.isArray(messages)) return extracted; - for (const message of messages) { - if (!isRecord(message)) continue; - const role = typeof message.role === "string" ? message.role : "unknown"; - const text = extractMessageText(message.content); - if (text.length > 0) extracted.push({ role, text }); - } - return extracted; -} - -function extractMessageText(content: unknown): string { - if (typeof content === "string") return content; - if (!Array.isArray(content)) return ""; - const parts: string[] = []; - for (const part of content) { - if (typeof part === "string") { - parts.push(part); - continue; - } - if (!isRecord(part)) continue; - if (typeof part.text === "string") parts.push(part.text); - } - return parts.join("\n"); -} - -/** Selection the family expects for a given request kind and session agent. */ -function expectedSelection( - capture: import("./types.js").FamilyCapture, - agentID: string | undefined, -): ModelSelection | undefined { - // An explicit family choice is the session's model and must not be overridden - // by an agent-role binding. - if (capture.intent.mode === "explicit" && capture.modelOverride !== undefined) { - return qualifySelection(capture, capture.modelOverride); - } - if (agentID !== undefined) { - const binding = capture.agents.find((agent) => agent.agentID === agentID); - if (binding?.selection !== undefined) { - return qualifySelection(capture, binding.selection); - } - } - const base = primarySelection(capture); - return base === undefined ? undefined : qualifySelection(capture, base); -} - -function selectionMatches( - actual: ModelSelection | undefined, - expected: ModelSelection | undefined, -): boolean { - if (expected === undefined) return true; - if (actual === undefined) return false; - // Identity plus variant: a resolved request must use the captured variant exactly. - return sameModelSelection(actual, expected); -} - -/** Map a family-qualified variant id back to its native source variant. */ -function sourceVariantOf( - capture: import("./types.js").FamilyCapture, - selection: ModelSelection | undefined, -): string | undefined { - if (selection?.variant === undefined) return undefined; - const known = capture.variants.find((variant) => variant.id === selection.variant); - return known?.sourceVariant ?? selection.variant; -} - -/** - * True when two selections denote the same model and the same source variant, - * accepting the native variant id and its family-qualified id as equivalent. - * The host may resolve either form depending on whether the session model was - * rewritten through the family switch, and both denote the same request body. - */ -function sameSourceSelection( - capture: import("./types.js").FamilyCapture, - actual: ModelSelection | undefined, - expected: ModelSelection | undefined, -): boolean { - if (actual === undefined || expected === undefined) return false; - return ( - actual.providerID === expected.providerID && - actual.modelID === expected.modelID && - sourceVariantOf(capture, actual) === sourceVariantOf(capture, expected) - ); -} - -/** The model a family expects for host title work: the captured fast/small role. */ -function titleExpectedSelection( - capture: import("./types.js").FamilyCapture, -): ModelSelection | undefined { - const small = capture.roleModels.fast; - return small === undefined ? undefined : qualifySelection(capture, small); -} - +// START_BLOCK_RUNTIME_INITIALIZATION function initializeNativeSnapshotRuntime( ctx: NativeSnapshotContext, options: NativeSnapshotRuntimeOptions | undefined, @@ -840,127 +643,25 @@ function initializeNativeSnapshotRuntime( const state: { config: EffectiveRuntimeConfig; agentBindings: AgentPolicyBinding[]; - modelSettings: CapturedModelSettings[]; nativeDefault: ModelSelection | undefined; - registrations: VariantRegistration[]; - pendingRegistrations: Map; - materializedVariants: Set; - ownVariantIds: Set; roleOverride: boolean; configError: string | undefined; - creationModels: Map; - creationDefaults: Map; - explicitSelections: Map; - pluginSwitches: Map; - suppressedTitles: Set; - firstWorkPending: Set; } = { config: base, agentBindings: [], - modelSettings: [], nativeDefault: undefined, - registrations: [], - // Per-family pending materialization so concurrent families cannot overwrite - // each other's in-flight variant registrations. - pendingRegistrations: new Map(), - materializedVariants: new Set(), - ownVariantIds: new Set(), roleOverride: false, configError: undefined, - creationModels: new Map(), - creationDefaults: new Map(), - explicitSelections: new Map(), - pluginSwitches: new Map(), - suppressedTitles: new Set(), - // Sessions this runtime just bound and switched at first work. The host may - // resolve the dispatch against the pre-binding role base while the hook's - // own switch is still landing, so the guard tolerates exactly that one - // bare-base resolution instead of failing the session's first turn. - firstWorkPending: new Set(), }; - const store = options?.store ?? new FileBindingStore({ scopeKey: ctx.location.project.id }); - /** - * Read the host's final native model collection. A model transform registered - * by this plugin may run before later config transforms, so a transform-local - * editor is neither the capture source nor proof a variant survived; the - * host's final list is. Returns undefined when the host exposes no list call. + * Structural host session read used by lineage resolution, birth, and + * migration. Returns the checked session view shape. */ - async function readFinalModelEntries(): Promise< - ReadonlyArray> | undefined - > { - const list = (ctx.model as { list?: (input?: unknown) => Promise }).list; - if (typeof list !== "function") return undefined; - let raw: unknown; - try { - raw = await list.call(ctx.model); - } catch { - return undefined; - } - const data = - isRecord(raw) && Array.isArray((raw as { data?: unknown }).data) - ? (raw as { data: unknown[] }).data - : Array.isArray(raw) - ? raw - : []; - return data.filter(isRecord); - } - - const readFinalOverlays = async ( - config: EffectiveRuntimeConfig, - ): Promise => { - const entries = await readFinalModelEntries(); - if (entries === undefined) return undefined; - const wanted = wantedModelKeys(config); - const captured: CapturedModelSettings[] = []; - const overlay = ( - providerID: string, - modelID: string, - variant: string | undefined, - base: Record, - source: Record, - ): CapturedModelSettings => { - const settings = mergeModelBodyOverlay( - isRecord(base.settings) ? base.settings : undefined, - isRecord(source.settings) ? source.settings : undefined, - ); - const body = mergeModelBodyOverlay( - isRecord(base.body) ? base.body : undefined, - isRecord(source.body) ? source.body : undefined, - ); - const headers = mergeModelHeadersOverlay( - isRecord(base.headers) ? (base.headers as Record) : undefined, - isRecord(source.headers) ? (source.headers as Record) : undefined, - ); - return { - providerID, - modelID, - ...(variant === undefined ? {} : { variant }), - ...(settings === undefined ? {} : { settings }), - ...(body === undefined ? {} : { body }), - ...(headers === undefined ? {} : { headers }), - }; - }; - for (const entry of entries) { - const providerID = entry.providerID; - const modelID = entry.id; - if (typeof providerID !== "string" || typeof modelID !== "string") continue; - if (wanted.size > 0 && !wanted.has(`${providerID}/${modelID}`)) continue; - captured.push(overlay(providerID, modelID, undefined, entry, entry)); - if (Array.isArray(entry.variants)) { - for (const variant of entry.variants) { - if (!isRecord(variant) || typeof variant.id !== "string") continue; - // Never re-capture our own generated variants (no accumulation). - if (isManagedVariantID(variant.id, modelID, state.ownVariantIds)) continue; - // Each source variant gets the base merged with THAT variant's own - // native overlay, so two source variants of one model stay distinct. - captured.push(overlay(providerID, modelID, variant.id, entry, variant)); - } - } - } - return captured; - }; + const readSession = async ( + sessionID: string, + ): Promise => + toSessionView(await ctx.session.get({ sessionID })); const readConfig = async (directory: string): Promise => { const vvoc = effectiveRuntimeConfig(await loadEffectiveVvocConfig({ cwd: directory })); @@ -976,19 +677,12 @@ function initializeNativeSnapshotRuntime( ...vvoc, ...(rawIntent === undefined ? {} : { rawIntent }), }); - const baseConfig: EffectiveRuntimeConfig = { + return { ...vvoc, ...(rawIntent === undefined ? {} : { rawIntent }), ...(rootResolved === undefined ? {} : { rootDefault: rootResolved }), agentBindings, - modelSettings: state.modelSettings, - ...(state.nativeDefault === undefined ? {} : { nativeDefault: state.nativeDefault }), }; - // Capture the final native model overlay (post config-transform) when the - // host exposes it, so a snapshot variant reflects the real request payload. - const overlays = await readFinalOverlays(baseConfig); - if (overlays !== undefined) state.modelSettings = overlays; - return { ...baseConfig, modelSettings: state.modelSettings }; }; // Initialize the live config with raw OpenCode intent before any transform runs. @@ -1013,9 +707,7 @@ function initializeNativeSnapshotRuntime( const parent = await ctx.session.get({ sessionID: input.parentID }); const projectID = parent.projectID === undefined ? "" : String(parent.projectID); if (projectID.length === 0) { - throw new SnapshotAdmissionError( - "Auxiliary child import requires the parent session project id.", - ); + throw new Error("Auxiliary child import requires the parent session project id."); } const sessionID = `ses_${randomBytes(16).toString("hex")}`; const now = Date.now(); @@ -1050,9 +742,7 @@ function initializeNativeSnapshotRuntime( const imported = await client.session.import(payload); const importedID = decodeImportedSessionID(imported); if (importedID === undefined || importedID !== sessionID) { - throw new SnapshotAdmissionError( - "Native session import did not return the expected auxiliary child id.", - ); + throw new Error("Native session import did not return the expected auxiliary child id."); } // The parent binding must be durable before the child is used. for (let attempt = 0; attempt < 20; attempt += 1) { @@ -1060,323 +750,228 @@ function initializeNativeSnapshotRuntime( if (view.parentID === input.parentID) return { sessionID }; await new Promise((resolve) => setTimeout(resolve, 100)); } - throw new SnapshotAdmissionError( - "Imported auxiliary child did not persist its parent lineage.", - ); - }; - - const deps: SnapshotServiceDeps = { - store, - location: runtimeIdentity(ctx.location), - ...(options?.now === undefined ? {} : { now: options.now }), - // Distinct plugin contexts copied from one host share the native app/location - // object identity, so their snapshot services share one per-family - // publication lock without sharing any client, registration or disposal. - coordination: coordinateHost({ app: ctx.app, location: ctx.location }), - loadConfig: async (directory) => { - const fresh = await readConfig(directory); - state.config = fresh; - return fresh; - }, - readSession: async (sessionID) => toSessionView(await ctx.session.get({ sessionID })), - readSessionModel: async (sessionID) => - toSelection((await ctx.session.get({ sessionID })).model), - switchModel: ({ sessionID, model }) => - ctx.session.switchModel({ sessionID, model: toRef(model) }), - auxiliarySession: { - create: importAuxiliaryChild, - switchModel: async ({ sessionID, model }) => { - await ctx.session.switchModel({ sessionID, model: toRef(model) }); - }, - generate: (input) => ctx.session.generate(input), - }, - }; - const snapshots = createSnapshotService(deps); - - const allPending = (): VariantRegistration[] => - [...state.pendingRegistrations.values()].flat(); - - const applyVariants = (editor: ModelEditorLike): number => - applyVariantRegistrations(editor, [...state.registrations, ...allPending()]); - - const refresh = async (): Promise => { - state.registrations = buildVariantRegistrations(await snapshots.captures()); - await ctx.model.reload(); - await ctx.agent.reload(); + throw new Error("Imported auxiliary child did not persist its parent lineage."); }; + // START_BLOCK_SETTINGS_SERVICE /** - * Read the final native model collection after a reload. A model transform - * registered by this plugin may run before later config transforms, so a - * transform-local editor is not proof the variant survived; the host's final - * model list is. Returns undefined when the host exposes no list call (tests). + * Per-family RMW serialization. Distinct plugin contexts copied from one + * host share the native app/location object identity, so their settings + * services share one per-family lock without sharing any client, + * registration, or disposal. */ - const materializeBinding = async ( - capture: import("./types.js").FamilyCapture, - selection: ModelSelection, - ): Promise => { - if (selection.variant === undefined) return; - // Materialize this family's qualified live variant before its first switch. - state.pendingRegistrations.set(capture.snapshotId, buildVariantRegistrations([capture])); - await ctx.model.reload(); - const finalEntries = await readFinalModelEntries(); - const exists = - finalEntries === undefined - ? state.materializedVariants.has(selection.variant) - : finalEntries.some((entry) => { - if (entry.providerID !== selection.providerID || entry.id !== selection.modelID) { - return false; - } - return ( - Array.isArray(entry.variants) && - entry.variants.some( - (variant) => isRecord(variant) && String(variant.id) === selection.variant, - ) - ); - }); - if (!exists) { - state.pendingRegistrations.delete(capture.snapshotId); - await ctx.model.reload(); - throw new SnapshotAdmissionError( - `Captured variant ${selection.variant} is not materializable on ${selection.providerID}/${selection.modelID}.`, - ); - } + const coordination: HostCoordination | undefined = coordinateHost({ + app: ctx.app, + location: ctx.location, + }); + const familyLocks = new Map>(); + const withFamilyLock = (familyId: string, run: () => Promise): Promise => { + if (coordination !== undefined) return coordination.withFamilyLock(familyId, run); + const previous = familyLocks.get(familyId) ?? Promise.resolve(); + const next = previous.then(run, run); + familyLocks.set( + familyId, + next.then( + () => undefined, + () => undefined, + ), + ); + return next; }; - const switchSession = async (sessionID: string, selection: ModelSelection): Promise => { - state.pluginSwitches.set(sessionID, selection); - await ctx.session.switchModel({ sessionID, model: toRef(selection) }); + // Lineage and birth caches: lineage is stable for a session's lifetime, + // and the born fact is cached per process so the birth check stays one + // read per session ever. + const familyRoots = new Map(); + const bornRoots = new Set(); + const birthInFlight = new Map>(); + const reconciledChildren = new Set(); + + const diagnostic = (message: string): void => { + // Messages are static and bounded: never include paths, parsed state, or credentials. + state.configError = message.slice(0, 240); }; - /** First-work selection for a session's agent from the live policy. */ - const bindingTarget = (agentID: string | undefined): ModelSelection | undefined => { - const bindings = state.config.agentBindings ?? []; - if (agentID !== undefined) { - const binding = bindings.find((agent) => agent.agentID === agentID); - if (binding?.selection !== undefined) return binding.selection; - } - const roleModels = parseRoleSelections(state.config.roles); - if (roleModels.default !== undefined) return roleModels.default; - if (state.config.rootDefault !== undefined) return state.config.rootDefault; - for (const selection of Object.values(roleModels)) return selection; - for (const binding of bindings) { - if (binding.selection !== undefined) return binding.selection; - } - return state.config.nativeDefault; + const familyOf = async (sessionID: string): Promise => { + const cached = familyRoots.get(sessionID); + if (cached !== undefined) return cached; + const root = await resolveSessionFamily({ sessionID, readSession }); + familyRoots.set(sessionID, root); + return root; }; - /** - * One coherent workload selection for a bound family. An explicit post-bind - * user choice always wins; role overriding disabled means the host's current - * model is already the policy. Otherwise the agent's captured binding, then - * the family primary, with the family's captured variant qualified. - */ - const expectedForSession = ( - capture: import("./types.js").FamilyCapture, - agentID: string | undefined, - sessionID: string, - metadata?: Readonly> | undefined, - ): ModelSelection | undefined => { - // A real parented auxiliary child recovers its intended role from - // host-owned metadata, not an in-process exemption. - const auxiliary = decodeAuxiliaryMetadata(metadata); - if (auxiliary !== undefined) { - const base = - auxiliary.role === undefined - ? primarySelection(capture) - : capture.roleModels[auxiliary.role]; - return base === undefined ? undefined : qualifySelection(capture, base); + const buildView = ( + familyId: string, + settings: FamilySettings, + source: FamilySettingsView["source"], + root: import("./types.js").NativeSessionView, + ): FamilySettingsView | undefined => { + let roleModels; + try { + roleModels = parseRoleSelections(settings.roles); + } catch { + // A corrupt role binding must not fail the request path; the live + // configuration resolves instead, with a bounded diagnostic. + diagnostic("Family settings roles are invalid; resolving the live configuration."); + return undefined; } - if (!state.roleOverride) return capture.modelOverride ?? capture.rootSelection; - const explicit = state.explicitSelections.get(sessionID); - // An explicit user choice wins, but the host resolves the request against the - // family-qualified variant id after the first-work switch, so the expectation - // must use the same qualification or the guard rejects the resolved request. - if (explicit !== undefined) return qualifySelection(capture, explicit); - return expectedSelection(capture, agentID); + return { + familyId, + locationDirectory: root.locationDirectory, + ...(root.model === undefined ? {} : { rootModel: root.model }), + settings, + vvoc: projectFamilyConfig(settings, state.config.vvoc), + roleModels, + source, + settingsId: settingsId(settings), + }; }; - const bindFor = async (sessionID: string): Promise => { - await ensureTransforms(); - const view = toSessionView(await ctx.session.get({ sessionID })); - const sessionModel = view.model; - const pluginSwitch = state.pluginSwitches.get(sessionID); - const ownSwitch = - pluginSwitch !== undefined && sameModelSelection(sessionModel, pluginSwitch); - // The default captured at creation, never the possibly-changed current default. - const creationDefault = state.creationDefaults.get(sessionID) ?? state.nativeDefault; - const intent = ownSwitch - ? ({ mode: "implicit", source: "switch" } as const) - : classifyInitialSelection({ sessionModel, creationDefault }); - if (intent.mode === "explicit" && intent.literal !== undefined) { - // Creation provenance is an explicit host selection just like a later - // model-selected event, so preserve it through the model guard. - state.explicitSelections.set(sessionID, intent.literal); - } + const fallbackView = (familyId: string): FamilySettingsView | undefined => + buildView(familyId, settingsFromConfig(state.config.vvoc), "config", { id: familyId }); - const existing = await snapshots.policy(sessionID); - if (existing !== undefined) { - // A bound family owns an immutable capture, so its policy/model handling - // never consults the mutable current config. Resolve it before any fresh - // read: an invalid on-disk vvoc must not fail an already-bound family. - // A session model that changed outside our own switch is a user choice and - // must survive, even if the event pump has not recorded it yet. - const baseExpected = expectedSelection(existing, view.agent); - if ( - !ownSwitch && - baseExpected !== undefined && - sessionModel !== undefined && - !sameModelSelection(sessionModel, baseExpected) && - !sameModelSelection(sessionModel, pluginSwitch) - ) { - state.explicitSelections.set(sessionID, normalizeModelSelection(sessionModel)); - // Every recorded user choice ends the first-work tolerance, exactly - // like the event-pump path: a later bare-base dispatch is genuine - // drift again, never silently admitted through a stale window. - state.firstWorkPending.delete(sessionID); + /** + * Birth a root family record under the per-family lock: re-read the + * fresh metadata, skip when another writer won the race, otherwise + * resolve the current effective configuration and read-modify-write only + * the settings key. The current request keeps using the in-memory value. + */ + const birthRoot = async (familyId: string): Promise => { + const pending = birthInFlight.get(familyId); + if (pending !== undefined) return pending; + const run = withFamilyLock(familyId, async () => { + const fresh = await readSession(familyId); + const existing = readSettingsFromMetadata(fresh.metadata); + if (existing !== undefined && "settings" in existing) { + bornRoots.add(familyId); + return existing.settings; } - if (!state.roleOverride || state.explicitSelections.has(sessionID)) return; - const target = expectedForSession(existing, view.agent, sessionID); - if (target !== undefined && !sameModelSelection(sessionModel, target)) { - state.firstWorkPending.add(sessionID); - await switchSession(sessionID, target); + if (existing !== undefined && "error" in existing) { + diagnostic("Existing family settings are invalid; rebirthing from the live config."); } - return; - } - // Only a truly unbound family needs the freshest current config: - // vvoc.json changes do not emit a host `config.updated`, and a stale - // family target would otherwise bind the previous role selection. The - // SAME immutable value is passed into the service so its projection cannot - // re-read a different vvoc snapshot than the target was derived from. - const admissionConfig = await readConfig(ctx.location.directory); - state.config = admissionConfig; - const explicitSelection = intent.mode === "explicit" ? intent.literal : undefined; - const target = explicitSelection ?? bindingTarget(view.agent); - const base = state.roleOverride ? target : (sessionModel ?? target); - // The agent-aware resolved target drives binding, not the capture's default - // role; when role overriding is off the session's current model is bound. - const selectionOverride = - explicitSelection === undefined && base !== undefined ? base : undefined; - // The admission below switches this session onto the family selection; a - // dispatch the host resolved before that switch lands carries the bare - // role base, so the guard gets a one-shot tolerance for this session. - state.firstWorkPending.add(sessionID); - const outcome = await snapshots.admitOwned( - { - sessionID, - directory: ctx.location.directory, - location: runtimeIdentity(ctx.location), - admissionConfig, - ...(explicitSelection === undefined ? {} : { explicit: explicitSelection }), - ...(selectionOverride === undefined ? {} : { selectionOverride }), - force: state.roleOverride, - }, - async (capture, selection) => { - await materializeBinding(capture, selection); - state.pluginSwitches.set(sessionID, selection); - }, - ); - if (outcome.status === "rejected") { - throw new SnapshotAdmissionError( - outcome.error ?? `Binding for ${sessionID} did not produce a persisted policy.`, - ); - } - if (outcome.status === "bound") { - await refresh(); - state.pendingRegistrations.clear(); + // The birth record must reflect the CURRENT effective configuration: + // vvoc.json edits emit no host config.updated event, so the cached + // config can be stale exactly when a family is born right after a + // config change. The read is once per family and under the family + // lock; a failed fresh read falls back to the last fully read config. + let effective = state.config; + try { + effective = await readConfig(ctx.location.directory); + } catch { + diagnostic("Fresh config read failed at birth; using the cached effective config."); + } + const settings = settingsFromConfig(effective.vvoc); + const merged = mergeSettingsIntoMetadata(fresh.metadata, settings); + try { + await ctx.session.update({ sessionID: familyId, metadata: merged }); + bornRoots.add(familyId); + return settings; + } catch (error) { + // Fail soft: the family keeps resolving the live configuration and + // the next request retries the birth. + diagnostic(`Family settings birth failed: ${describePumpError(error).slice(0, 160)}`); + return undefined; + } + }); + birthInFlight.set(familyId, run); + try { + return await run; + } finally { + birthInFlight.delete(familyId); } }; - const guardResolvedModel = async ( + const resolveSettings = async ( sessionID: string, - agentID: string | undefined, - actual: ModelSelection | undefined, - kind: string, - ): Promise => { - await ensureTransforms(); - if (kind === "title" && state.suppressedTitles.has(sessionID)) { - // The title hook already supplied the result; the host still runs this - // hook before it checks `result`, so a refused dispatch here would fail - // a title that never reaches the provider. - state.suppressedTitles.delete(sessionID); - return; + ): Promise => { + let familyId: string; + try { + familyId = await familyOf(sessionID); + } catch (error) { + diagnostic(`Family resolution failed: ${describePumpError(error).slice(0, 160)}`); + return undefined; } - const view = toSessionView(await ctx.session.get({ sessionID })); - const effectiveAgent = agentID ?? view.agent; - const familyId = await snapshots.familyOf(sessionID); - const capture = await snapshots.policy(sessionID); - if (capture !== undefined) { - const expected = - kind === "title" - ? titleExpectedSelection(capture) - : expectedForSession(capture, effectiveAgent, sessionID, view.metadata); - // The first post-binding dispatch ends the first-work window whether - // it resolves clean or not: after it, sessions resolve their switched - // family model, so any bare-base dispatch is genuine drift again. - const firstWork = state.firstWorkPending.delete(sessionID); - if ( - !selectionMatches(actual, expected) && - !sameSourceSelection(capture, actual, expected) - ) { - // A dispatch resolved before this runtime's own first-work switch - // carries the bare role base of the very selection the switch just - // materialized. Accept that one pre-binding resolution; every later - // bare-base dispatch still rejects as drift. - if ( - firstWork && - actual !== undefined && - expected !== undefined && - actual.providerID === expected.providerID && - actual.modelID === expected.modelID && - actual.variant === undefined - ) { - return; - } - const describeSelection = (selection: ModelSelection | undefined): string => - selection === undefined - ? "unresolved" - : `${selection.providerID}/${selection.modelID}${selection.variant === undefined ? "" : `#${selection.variant}`}`; - throw new SnapshotAdmissionError( - `Resolved ${kind} model ${describeSelection(actual)} does not match the captured family selection ${describeSelection(expected)} for ${familyId}.`, - ); + let root: import("./types.js").NativeSessionView; + try { + root = await readSession(familyId); + } catch { + return fallbackView(familyId); + } + const read = readSettingsFromMetadata(root.metadata); + if (read !== undefined && "settings" in read) { + bornRoots.add(familyId); + const view = buildView(familyId, read.settings, "metadata", root); + return view ?? fallbackView(familyId); + } + if (read !== undefined && "error" in read) { + diagnostic("Family settings are invalid; resolving the live configuration."); + } + if (!bornRoots.has(familyId) && isFamilyRootView(root)) { + const born = await birthRoot(familyId); + if (born !== undefined) { + const view = buildView(familyId, born, "metadata", root); + return view ?? fallbackView(familyId); } - return; } - throw new SnapshotUnboundError( - `Refusing unbound ${kind} dispatch for session ${sessionID} before first-work family binding.`, - ); + return fallbackView(familyId); + }; + + const settingsService: FamilySettingsService = { + familyOf, + resolve: resolveSettings, + configFor: resolveSettings, + dispose() { + familyRoots.clear(); + bornRoots.clear(); + birthInFlight.clear(); + reconciledChildren.clear(); + familyLocks.clear(); + }, }; + /** + * One-shot creation reconciliation: a host-created child of a role-managed + * family adopts the family's role model exactly once at creation. There is + * no later validation; an explicit model choice after creation is never + * touched. + */ + const reconcileCreatedChild = async (created: { + readonly sessionID: string; + readonly model?: ModelSelection | undefined; + }): Promise => { + if (!state.roleOverride) return; + if (reconciledChildren.has(created.sessionID)) return; + reconciledChildren.add(created.sessionID); + const view = await readSession(created.sessionID).catch(() => undefined); + if (view === undefined) return; + const auxiliary = decodeAuxiliaryMetadata(view.metadata); + if (auxiliary !== undefined) return; + if (view.agent !== undefined && INTERNAL_PRIMARY_AGENTS.has(view.agent)) return; + const role = view.agent === undefined ? undefined : state.config.agentRoles[view.agent]; + if (role === undefined) return; + const settings = await resolveSettings(created.sessionID); + if (settings === undefined) return; + const expected = settings.roleModels[role]; + if (expected === undefined) return; + if (sameModelSelection(created.model ?? view.model, expected)) return; + await ctx.session.switchModel({ sessionID: created.sessionID, model: toRef(expected) }); + }; + // END_BLOCK_SETTINGS_SERVICE + const modelTransform = (editor: ModelEditorLike): void => { - const own = new Set( - [...state.registrations, ...allPending()].map((registration) => registration.variant.id), - ); - state.ownVariantIds = own; - state.modelSettings = captureModelSettings(editor, wantedModelKeys(state.config), own); state.nativeDefault = readDefaultModel(editor) ?? state.nativeDefault; - applyVariants(editor); - // Cheap transform-local readback; the final-list readback happens before switch. - state.materializedVariants = new Set( - [...state.registrations, ...allPending()].flatMap((registration) => { - const model = editor.get(registration.providerID, registration.modelID); - return model !== undefined && - model.variants.some((variant) => String(variant.id) === registration.variant.id) - ? [registration.variant.id] - : []; - }), - ); if (!state.roleOverride) return; const defaultRole = parseRoleSelections(state.config.roles).default; if (defaultRole !== undefined) { editor.default.set(defaultRole.providerID, defaultRole.modelID); } }; - // Global agent transform represents current unbound candidates only; family - // variants are applied per-session at switch time, never from another family. + // The agent transform applies role policies to agent definitions, so + // newly created sessions resolve their role model natively at creation. const agentTransform = (editor: AgentEditorLike): void => { state.agentBindings = captureAgentBindings(editor, state.config); if (state.roleOverride) { - applyAgentPolicies(editor, state.agentBindings, []); + applyAgentPolicies(editor, state.agentBindings); } }; @@ -1389,7 +984,7 @@ function initializeNativeSnapshotRuntime( /** * Eager registration gives the role toggle an immediate visible effect during * setup; post config plugins register after it, so it also needs a suffix - * registration after activation that appends last and keeps our variants. + * registration after activation that appends last. */ const ensureEagerTransforms = async (): Promise => { if (eagerTransformsRegistered) return; @@ -1405,34 +1000,28 @@ function initializeNativeSnapshotRuntime( await ctx.model.reload(); await ctx.agent.reload(); }; - owned.push(await ctx.session.hook("prompt", (event) => bindFor(event.sessionID))); - // `context` and `generate` resolve their model before (or around) the hook, - // so neither can bind; owned generate/synthetic work uses admitWorkload. - owned.push( - await ctx.session.hook("generate", async (event) => { - const view = toSessionView(await ctx.session.get({ sessionID: event.sessionID })); - await guardResolvedModel(event.sessionID, view.agent, view.model, "generate"); - }), - ); - owned.push( - await ctx.session.hook("model.request", async (event) => { - // The model is already resolved here: only validate/commit, never re-stage. - await guardResolvedModel( - event.sessionID, - event.agent, - toSelection(event.model), - event.kind, - ); - }), - ); + const rawSmallModel = state.config.rawIntent?.smallModel; const smallModelRole = rawSmallModel !== undefined && isRoleReference(rawSmallModel) ? rawSmallModel.trim().slice(ROLE_REFERENCE_PREFIX.length) : getBuiltInRoleBindings().opencodeDefaults.smallModel; + const auxiliary = createAuxiliaryService({ + familyOf, + policy: resolveSettings, + session: { + create: importAuxiliaryChild, + switchModel: async ({ sessionID, model }) => { + await ctx.session.switchModel({ sessionID, model: toRef(model) }); + }, + generate: (input) => ctx.session.generate(input), + }, + fallbackDirectory: ctx.location.directory, + }); + owned.push( await ctx.session.hook("title", async (event) => { - const title = await snapshots.auxiliary.title({ + const title = await auxiliary.title({ sessionID: event.sessionID, result: event.result, messages: extractTitleMessages(event.messages), @@ -1440,59 +1029,49 @@ function initializeNativeSnapshotRuntime( }); if (title !== undefined && event.result !== title) { event.result = title; - // The host runs model.request before it checks `result`, so remember - // that this title provider dispatch is suppressed and must not refuse. - state.suppressedTitles.add(event.sessionID); } }), ); - // One native event pump: creation provenance, explicit choice capture, config - // refresh, and family-deletion cleanup share a single subscription. Failures - // are contained per event so later updates still recover runtime health. + // Lazy settings birth on the first context hook of a session. The hook is + // fail-soft: resolution problems never fail a model request, and plugin + // consumers resolve independently anyway. + owned.push( + await ctx.session.hook("context", async (event) => { + try { + await ensureTransforms(); + await resolveSettings(event.sessionID); + } catch { + // Never fail a model request from settings work. + } + }), + ); + + // One native event pump: creation reconciliation and config refresh share a + // single subscription. Failures are contained per event so later updates + // still recover runtime health. const pump = (async () => { try { for await (const event of ctx.event.subscribe({ signal: lifecycle.signal })) { try { const created = decodeSessionCreatedEvent(event); if (created !== undefined) { - state.creationModels.set(created.sessionID, created.model); - // Record the default in force at creation, not a later changed default. - state.creationDefaults.set(created.sessionID, state.nativeDefault); - } - const selected = decodeModelSelectedEvent(event); - if (selected !== undefined) { - const pluginSwitch = state.pluginSwitches.get(selected.sessionID); - if ( - pluginSwitch === undefined || - !sameModelSelection(selected.model, pluginSwitch) - ) { - // The host emits this only for a real change, so it is a user choice. - state.explicitSelections.set(selected.sessionID, selected.model); - // A user choice ends the first-work tolerance: later bare-base - // dispatches are genuine drift again. - state.firstWorkPending.delete(selected.sessionID); - } + await reconcileCreatedChild(created); } if (isConfigUpdateEvent(event)) { // Assign only after a fully successful read so a partially read // invalid config never becomes the mutable current config that - // already bound families consult. + // consumers consult; a successful read also clears the last + // contained failure. state.config = await readConfig(ctx.location.directory); - await refresh(); - // A successful config event is the recovery signal. state.configError = undefined; } if (event.type === "session.deleted" && isRecord(event.data)) { const sessionID = event.data.sessionID; if (typeof sessionID === "string") { - await snapshots.removeFamily(sessionID); - state.creationModels.delete(sessionID); - state.creationDefaults.delete(sessionID); - state.explicitSelections.delete(sessionID); - state.pluginSwitches.delete(sessionID); - state.suppressedTitles.delete(sessionID); - state.firstWorkPending.delete(sessionID); + familyRoots.delete(sessionID); + bornRoots.delete(sessionID); + reconciledChildren.delete(sessionID); } } } catch (error) { @@ -1508,7 +1087,24 @@ function initializeNativeSnapshotRuntime( })(); void pump; - await refresh(); + await ensureEagerTransforms(); + + // One-shot legacy migration: convert existing family bindings into root + // metadata, then prune the legacy directories. Fail-soft: any write + // failure keeps the directories for the next start. + try { + await migrateFamilyBindings({ + reader: fileLegacyBindingsReader({ scopeKey: ctx.location.project.id }), + readSession, + writeSettings: async (sessionID, settings) => { + const fresh = await readSession(sessionID); + const merged = mergeSettingsIntoMetadata(fresh.metadata, settings); + await ctx.session.update({ sessionID, metadata: merged }); + }, + }); + } catch (error) { + diagnostic(`Legacy migration deferred: ${describePumpError(error).slice(0, 160)}`); + } // Read-only native context-inspection RPC: projects the registered tool // catalog and an allowlisted family/current-runtime policy for the TUI. @@ -1522,7 +1118,7 @@ function initializeNativeSnapshotRuntime( session: { get: (input) => ctx.session.get(input) }, }, { - policy: (sessionID) => snapshots.policy(sessionID), + policy: (sessionID) => resolveSettings(sessionID), currentConfig: () => state.config, ...(options?.now === undefined ? {} : { now: options.now }), }, @@ -1531,40 +1127,24 @@ function initializeNativeSnapshotRuntime( return { runtime, - snapshots, - auxiliary: snapshots.auxiliary, + settings: settingsService, + auxiliary, client: () => runtime.client(), permissions: runtime.permissions, effectiveConfig: () => state.config, - async admitWorkload(request) { - // Supported pre-admission gateway for owned direct generate/synthetic: - // bind, materialize the qualified live variant, and switch before the - // caller triggers native model resolution. - const admissionConfig = await readConfig(request.directory); - state.config = admissionConfig; - const outcome = await snapshots.admitOwned( - { - ...request, - admissionConfig, - }, - async (capture, selection) => { - await materializeBinding(capture, selection); - state.pluginSwitches.set(request.sessionID, selection); - }, - ); - if (outcome.status === "bound") { - await refresh(); - state.pendingRegistrations.clear(); - } - return outcome; - }, async setRoleOverride(enabled) { state.roleOverride = enabled; - // Immediate visible override; the suffix registration at first work - // appends after post config transforms and keeps the final state ours. + // Immediate visible override: re-run the already-registered + // transforms with the new state instead of waiting for the first + // request, without stacking duplicate registrations. await ensureEagerTransforms(); + await ctx.model.reload(); + await ctx.agent.reload(); + }, + refresh: async () => { + await ctx.model.reload(); + await ctx.agent.reload(); }, - refresh, lastConfigError: () => state.configError, release: releaseRuntime, }; @@ -1576,7 +1156,7 @@ function initializeNativeSnapshotRuntime( })(); } -/** Acquire the shared native snapshot runtime for an actual `Plugin.Context`. */ +/** Acquire the shared native runtime for an actual `Plugin.Context`. */ export async function acquireNativeSnapshotRuntime< Client extends NativeForkClient = OpenCodeClient, >( @@ -1626,4 +1206,4 @@ async function releaseNativeSnapshotRuntime(key: string, entry: NativeRuntimeEnt const runtime = entry.runtime ?? (await entry.init); await runtime.release(); } -// END_BLOCK_NATIVE_SNAPSHOT_RUNTIME +// END_BLOCK_RUNTIME_INITIALIZATION diff --git a/src/runtime/model-registry.test.ts b/src/runtime/model-registry.test.ts index ee346871..92ba049a 100644 --- a/src/runtime/model-registry.test.ts +++ b/src/runtime/model-registry.test.ts @@ -1,8 +1,8 @@ // FILE: src/runtime/model-registry.test.ts -// VERSION: 2.0.0 +// VERSION: 3.0.0 // START_MODULE_CONTRACT -// PURPOSE: Verify family-qualified variant materialization on real native models and snapshot-bound agent policy application. -// SCOPE: Managed MiMo thinking on the real #thinking model only, missing-model skip, family-filtered projection, family qualification, and literal-model preservation using real native schema fixtures; no host. +// PURPOSE: Verify creation-level agent policy application onto real native agents, preserving explicit literal choices. +// SCOPE: Missing-agent skip, literal-model preservation, and plain selection application using real native schema fixtures; no host. // DEPENDS: [bun:test, @opencode/schema/agent, @opencode/schema/model, @opencode/schema/provider, src/runtime/model-registry.ts] // LINKS: [M-NATIVE-RUNTIME, V-M-NATIVE-RUNTIME] // ROLE: TEST @@ -10,89 +10,24 @@ // END_MODULE_CONTRACT // // START_MODULE_MAP -// ModelInfo - Native model info type alias used by the editor double. // AgentInfo - Native agent info type alias used by the editor double. -// ModelEditorFixture - In-memory native-shaped model editor fixture type. // AgentEditorFixture - In-memory native-shaped agent editor fixture type. -// createModelEditor - Build an in-memory native-shaped model editor double. // createAgentEditor - Build an in-memory native-shaped agent editor double. -// captureWith - Build a minimal family capture fixture. // END_MODULE_MAP // // START_CHANGE_SUMMARY -// LAST_CHANGE: [C-OPENCODE-V2-NATIVE T-002 - Family-qualified variant and real-MiMo rule coverage.] +// LAST_CHANGE: [C-SESSION-OWNED-SETTINGS T-002 - Coverage narrowed to the surviving creation-level agent-policy application.] // END_CHANGE_SUMMARY import { describe, expect, test } from "bun:test"; import { Agent } from "@opencode/schema/agent"; import { Model } from "@opencode/schema/model"; import { Provider } from "@opencode/schema/provider"; -import { createDefaultVvocConfig } from "../lib/vvoc-config.js"; -import { - applyAgentPolicies, - applyVariantRegistrations, - buildVariantRegistrations, - managedVariantFor, - mergeModelBodyOverlay, - mergeModelHeadersOverlay, - primarySelection, - qualifySelection, -} from "./model-registry.js"; -import type { - AgentEditorLike, - FamilyCapture, - ModelEditorLike, - ModelSelection, - VariantRegistration, -} from "./types.js"; +import { applyAgentPolicies } from "./model-registry.js"; +import type { AgentEditorLike, ModelSelection } from "./types.js"; -type ModelInfo = ReturnType[number]; type AgentInfo = NonNullable>; -interface ModelEditorFixture { - readonly editor: ModelEditorLike; - get(providerID: string, modelID: string): ModelInfo | undefined; - add(providerID: string, modelID: string): void; - setDefault(providerID: string, modelID: string): void; - default(): { providerID: string; modelID: string } | undefined; -} - -function createModelEditor(): ModelEditorFixture { - const models = new Map(); - let currentDefault: { providerID: string; modelID: string } | undefined; - const editor: ModelEditorLike = { - list: (providerID) => - [...models.values()].filter( - (model) => providerID === undefined || String(model.providerID) === providerID, - ), - get: (providerID, modelID) => models.get(`${providerID}/${modelID}`), - update: (providerID, modelID, update) => { - const model = models.get(`${providerID}/${modelID}`); - if (model !== undefined) update(model); - }, - default: { - get: () => currentDefault, - set: (providerID, modelID) => { - currentDefault = { providerID, modelID }; - }, - }, - }; - return { - editor, - get: (providerID, modelID) => models.get(`${providerID}/${modelID}`), - add(providerID, modelID) { - models.set( - `${providerID}/${modelID}`, - Model.Info.default(Provider.ID.make(providerID), Model.ID.make(modelID)), - ); - }, - setDefault(providerID, modelID) { - currentDefault = { providerID, modelID }; - }, - default: () => currentDefault, - }; -} - interface AgentEditorFixture { readonly editor: AgentEditorLike; add(id: string, model?: ModelSelection): AgentInfo; @@ -125,173 +60,45 @@ function createAgentEditor(): AgentEditorFixture { }; } -function captureWith( - variants: FamilyCapture["variants"], - overrides: Partial = {}, -): FamilyCapture { - return { - schemaVersion: 1, - snapshotId: "snap", - integrity: "integrity", - familyId: "ses_root", - capturedAt: 1, - location: { directory: "/project", projectID: "proj", canonical: "/project" }, - roles: {}, - roleModels: {}, - agents: [], - variants, - modelSettings: [], - vvoc: createDefaultVvocConfig(), - intent: { mode: "implicit", source: "config" }, - ...overrides, - }; -} - -describe("managedVariantFor", () => { - test("emits a thinking body only for the real MiMo #thinking model", () => { - const mimo = managedVariantFor( - { providerID: "xiaomi", modelID: "mimo-v2.6-flash", variant: "thinking" }, - "snap", - ); - expect(mimo?.body).toEqual({ thinking: { type: "enabled" } }); - expect(mimo?.sourceVariant).toBe("thinking"); - expect(mimo?.id).toBe("snap.mimo-v2.6-flash.thinking"); - - expect( - managedVariantFor({ providerID: "xiaomi", modelID: "mimo-v2.6-flash" }, "snap"), - ).toBeUndefined(); - expect( - managedVariantFor( - { providerID: "xiaomi", modelID: "other-model", variant: "thinking" }, - "snap", - ), - ).toBeUndefined(); - expect(managedVariantFor({ providerID: "openai", modelID: "gpt-5.4" }, "snap")).toBeUndefined(); - }); -}); - -describe("applyVariantRegistrations", () => { - test("adds a family-qualified variant to a real model and skips missing models", () => { - const registry = createModelEditor(); - registry.add("xiaomi", "mimo-v2.6-flash"); - const registrations: VariantRegistration[] = [ - { - providerID: "xiaomi", - modelID: "mimo-v2.6-flash", - variant: { - id: "snap.mimo-v2.6-flash.thinking", - providerID: "xiaomi", - modelID: "mimo-v2.6-flash", - sourceVariant: "thinking", - body: { thinking: { type: "enabled" } }, - }, - }, - { - providerID: "ghost", - modelID: "missing", - variant: { id: "snap.missing", providerID: "ghost", modelID: "missing" }, - }, - ]; - - expect(applyVariantRegistrations(registry.editor, registrations)).toBe(1); - const model = registry.get("xiaomi", "mimo-v2.6-flash"); - expect(model?.variants.map((variant) => String(variant.id))).toEqual([ - "snap.mimo-v2.6-flash.thinking", - ]); - expect(registry.get("ghost", "missing")).toBeUndefined(); - expect(applyVariantRegistrations(registry.editor, registrations)).toBe(0); - }); - - test("buildVariantRegistrations filters by family and primarySelection prefers default", () => { - const first = captureWith( - [{ id: "a.m1", providerID: "prov", modelID: "m1", sourceVariant: "thinking" }], - { familyId: "family-a", roleModels: { default: { providerID: "prov", modelID: "m1" } } }, - ); - const second = captureWith( - [{ id: "b.m1", providerID: "prov", modelID: "m1", sourceVariant: "thinking" }], - { familyId: "family-b" }, - ); - expect(buildVariantRegistrations([first, second], "family-b").map((r) => r.variant.id)).toEqual( - ["b.m1"], - ); - expect(primarySelection(first)).toEqual({ providerID: "prov", modelID: "m1" }); - }); - - test("qualifySelection picks the family's own variant and respects source variants", () => { - const capture = captureWith([ - { id: "a.m1.thinking", providerID: "prov", modelID: "m1", sourceVariant: "thinking" }, - { id: "a.m1.plain", providerID: "prov", modelID: "m1" }, - ]); - expect(qualifySelection(capture, { providerID: "prov", modelID: "m1" })).toEqual({ - providerID: "prov", - modelID: "m1", - variant: "a.m1.thinking", - }); - expect( - qualifySelection(capture, { providerID: "prov", modelID: "m1", variant: "thinking" }), - ).toEqual({ providerID: "prov", modelID: "m1", variant: "a.m1.thinking" }); - expect(qualifySelection(capture, { providerID: "prov", modelID: "other" })).toEqual({ - providerID: "prov", - modelID: "other", - }); - }); -}); - describe("applyAgentPolicies", () => { - test("sets snapshot-qualified models only for unset agents and preserves literal models", () => { + test("sets role models only for unset agents and preserves literal models", () => { const agents = createAgentEditor(); const unset = agents.add("build"); const literal = agents.add("plan", { providerID: "user", modelID: "chosen" }); - const applied = applyAgentPolicies( - agents.editor, - [ - { agentID: "build", role: "default", selection: { providerID: "prov", modelID: "m1" } }, - { agentID: "plan", role: "smart", selection: { providerID: "prov", modelID: "m2" } }, - { agentID: "missing", role: "fast", selection: { providerID: "prov", modelID: "m3" } }, - ], - [ - { - providerID: "prov", - modelID: "m1", - variant: { id: "snap.m1", providerID: "prov", modelID: "m1" }, - }, - ], - ); + const applied = applyAgentPolicies(agents.editor, [ + { agentID: "build", role: "default", selection: { providerID: "prov", modelID: "m1" } }, + { agentID: "plan", role: "smart", selection: { providerID: "prov", modelID: "m2" } }, + { agentID: "missing", role: "fast", selection: { providerID: "prov", modelID: "m3" } }, + ]); expect(applied).toBe(1); expect(unset.model).toEqual({ id: Model.ID.make("m1"), providerID: Provider.ID.make("prov"), - variant: Model.VariantID.make("snap.m1"), }); expect(literal.model).toEqual({ id: Model.ID.make("chosen"), providerID: Provider.ID.make("user"), }); }); -}); -describe("mergeModelBodyOverlay / mergeModelHeadersOverlay", () => { - test("merges nested records with the host's overlay semantics", () => { - expect(mergeModelBodyOverlay({ a: { b: 1, c: 2 } }, { a: { c: 3, d: 4 } })).toEqual({ - a: { b: 1, c: 3, d: 4 }, - }); - // Arrays are replaced wholesale, scalars are overridden, undefined keeps base. - expect(mergeModelBodyOverlay({ a: [1, 2], s: "x" }, { a: [3], s: undefined })).toEqual({ - a: [3], - s: "x", - }); - expect(mergeModelBodyOverlay(undefined, { a: 1 })).toEqual({ a: 1 }); - expect(mergeModelBodyOverlay({ a: 1 }, undefined)).toEqual({ a: 1 }); - }); - - test("merges headers case-insensitively with the variant winning", () => { - expect(mergeModelHeadersOverlay({ "X-A": "1", "X-B": "2" }, { "x-a": "3", Y: "4" })).toEqual({ - "x-a": "3", - "X-B": "2", - Y: "4", + test("applies the selection variant verbatim and counts each application", () => { + const agents = createAgentEditor(); + const unset = agents.add("build"); + const applied = applyAgentPolicies(agents.editor, [ + { + agentID: "build", + role: "smart", + selection: { providerID: "prov", modelID: "m2", variant: "thinking" }, + }, + { agentID: "empty", selection: undefined }, + ]); + expect(applied).toBe(1); + expect(unset.model).toEqual({ + id: Model.ID.make("m2"), + providerID: Provider.ID.make("prov"), + variant: Model.VariantID.make("thinking"), }); - expect(mergeModelHeadersOverlay(undefined, undefined)).toBeUndefined(); }); }); diff --git a/src/runtime/model-registry.ts b/src/runtime/model-registry.ts index 165cd196..42b46937 100644 --- a/src/runtime/model-registry.ts +++ b/src/runtime/model-registry.ts @@ -1,8 +1,8 @@ // FILE: src/runtime/model-registry.ts -// VERSION: 2.0.0 +// VERSION: 3.0.0 // START_MODULE_CONTRACT -// PURPOSE: Materialize family-specific snapshot-qualified model variants and snapshot-bound agent policies onto the real native provider/model collection. -// SCOPE: Managed variant derivation for the real MiMo thinking model only, family-filtered capture-to-registration projection, replay of persisted captures onto real models only, family-qualified selection qualification, and snapshot-bound agent models that preserve explicit literal choices. Never invents alias providers/models and never emits a PDF declaration. +// PURPOSE: Apply role-policy agent models onto the real native agent collection for newly created sessions, preserving explicit literal choices. +// SCOPE: Agent-policy application that only fills agents without an explicit literal model. Never invents alias providers/models, never touches model variants or per-family qualified selections, and never mutates explicit user choices. // DEPENDS: [@opencode/schema/agent, @opencode/schema/model, @opencode/schema/provider, src/runtime/types.ts] // LINKS: [M-NATIVE-RUNTIME, V-M-NATIVE-RUNTIME] // ROLE: RUNTIME @@ -10,251 +10,31 @@ // END_MODULE_CONTRACT // // START_MODULE_MAP -// MIMO_PROVIDER_ID - Managed provider whose real thinking model has a managed variant. -// MIMO_THINKING_MODEL_ID - Real model id that carries the managed thinking variant. -// managedVariantFor - Derive a managed snapshot variant for one model selection. -// primarySelection - Select the capture's default model for admission and bound auxiliary work. -// qualifySelection - Qualify a family selection with its own captured variant. -// buildVariantRegistrations - Project captures into family-specific variant registrations. -// applyVariantRegistrations - Publish registrations onto real models present in the native editor. -// applyAgentPolicies - Apply snapshot-bound agent selections without overwriting explicit literal models. -// isManagedThinkingModel - True only for the real MiMo thinking model selection. -// mergeModelBodyOverlay - Merge a variant body over a base body with the pinned host's recursive semantics. -// mergeModelHeadersOverlay - Merge variant headers over base headers case-insensitively, variant winning. +// applyAgentPolicies - Apply role-policy agent selections without overwriting explicit literal models. // END_MODULE_MAP // // START_CHANGE_SUMMARY -// LAST_CHANGE: [C-OPENCODE-V2-NATIVE T-002 attempt 6 - Added native-semantics overlay merging for base+source-variant capture.] +// LAST_CHANGE: [C-SESSION-OWNED-SETTINGS T-002 - Dropped variant materialization, family-qualified selection, managed variants, and overlay merging; only creation-level agent-policy application remains.] // END_CHANGE_SUMMARY import { Model } from "@opencode/schema/model"; import { Provider } from "@opencode/schema/provider"; -import { - type AgentEditorLike, - type AgentPolicyBinding, - type FamilyCapture, - type ModelEditorLike, - type ModelSelection, - type ModelVariantCapture, - type VariantRegistration, -} from "./types.js"; +import type { AgentEditorLike, AgentPolicyBinding, ModelSelection } from "./types.js"; -/** Managed provider whose real thinking model has a managed variant. */ -export const MIMO_PROVIDER_ID = "xiaomi"; - -/** Real model id that carries the managed thinking variant. */ -export const MIMO_THINKING_MODEL_ID = "mimo-v2.6-flash"; - -/** Managed MiMo thinking body: enables thinking, never reasoningEffort, never PDF. */ -const MIMO_THINKING_BODY = Object.freeze({ thinking: Object.freeze({ type: "enabled" }) }); - -/** True only for the real MiMo thinking model selection (`xiaomi/mimo-v2.6-flash#thinking`). */ -export function isManagedThinkingModel(selection: ModelSelection): boolean { - return ( - selection.providerID === MIMO_PROVIDER_ID && - selection.modelID === MIMO_THINKING_MODEL_ID && - selection.variant === "thinking" - ); -} - -/** - * Derive a managed snapshot variant. The snapshot id is assigned by the capture - * builder, so `snapshotId` may be empty here and is filled when ids are stamped. - */ -export function managedVariantFor( - selection: ModelSelection, - snapshotId: string, -): ModelVariantCapture | undefined { - if (!isManagedThinkingModel(selection)) return undefined; - const sourceVariant = "thinking"; - return { - id: snapshotId === "" ? "" : `${snapshotId}.${selection.modelID}.${sourceVariant}`, - providerID: selection.providerID, - modelID: selection.modelID, - sourceVariant, - body: { ...MIMO_THINKING_BODY }, - }; -} - -/** Select the capture's default model for admission and bound auxiliary work. */ -export function primarySelection(capture: FamilyCapture): ModelSelection | undefined { - if (capture.modelOverride !== undefined) return capture.modelOverride; - if (capture.rootSelection !== undefined) return capture.rootSelection; - const defaultRole = capture.roleModels.default; - if (defaultRole !== undefined) return defaultRole; - for (const selection of Object.values(capture.roleModels)) return selection; - for (const agent of capture.agents) { - if (agent.selection !== undefined) return agent.selection; - } - return undefined; -} - -/** Qualify a family selection with its own captured variant. */ -export function qualifySelection( - capture: FamilyCapture, - selection: ModelSelection, -): ModelSelection { - if (selection.variant !== undefined) { - // Preserve an explicit source variant; only map it to the captured variant id. - const explicit = capture.variants.find( - (variant) => - variant.providerID === selection.providerID && - variant.modelID === selection.modelID && - variant.sourceVariant === selection.variant, - ); - return explicit === undefined ? selection : { ...selection, variant: explicit.id }; - } - const variant = capture.variants.find( - (candidate) => - candidate.providerID === selection.providerID && candidate.modelID === selection.modelID, - ); - return variant === undefined ? selection : { ...selection, variant: variant.id }; -} - -// START_BLOCK_OVERLAY_MERGE -function isPlainRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -/** - * Merge a variant body/settings overlay over a base overlay using the pinned - * host's `Provider.mergeOverlay` semantics: nested records merge recursively, - * the overlay wins on scalar conflicts, `undefined` overlay entries keep the - * base value, and arrays are replaced wholesale. - */ -export function mergeModelBodyOverlay( - base: Readonly> | undefined, - overlay: Readonly> | undefined, -): Record | undefined { - if (base === undefined) return overlay === undefined ? undefined : { ...overlay }; - if (overlay === undefined) return { ...base }; - const merged: Record = {}; - for (const key of new Set([...Object.keys(base), ...Object.keys(overlay)])) { - const left = base[key]; - const right = overlay[key]; - if (right === undefined) { - merged[key] = left; - continue; - } - merged[key] = - isPlainRecord(left) && isPlainRecord(right) - ? (mergeModelBodyOverlay(left, right) ?? {}) - : right; - } - return merged; -} - -/** - * Merge variant headers over base headers using the host's `mergeHeaders` - * semantics: header names are compared case-insensitively and the overlay wins. - */ -export function mergeModelHeadersOverlay( - base: Readonly> | undefined, - overlay: Readonly> | undefined, -): Record | undefined { - if (base === undefined) return overlay === undefined ? undefined : { ...overlay }; - if (overlay === undefined) return { ...base }; - const merged = new Map(); - for (const [key, value] of Object.entries(base)) merged.set(key.toLowerCase(), [key, value]); - for (const [key, value] of Object.entries(overlay)) merged.set(key.toLowerCase(), [key, value]); - return Object.fromEntries(merged.values()); -} -// END_BLOCK_OVERLAY_MERGE - -// START_BLOCK_VARIANT_REGISTRY -/** Project captures into family-specific variant registrations. */ -export function buildVariantRegistrations( - captures: readonly FamilyCapture[], - familyId?: string, -): VariantRegistration[] { - const registrations: VariantRegistration[] = []; - const seen = new Set(); - for (const capture of captures) { - if (familyId !== undefined && capture.familyId !== familyId) continue; - for (const variant of capture.variants) { - const key = `${capture.familyId}\u0000${variant.providerID}\u0000${variant.modelID}\u0000${variant.id}`; - if (seen.has(key)) continue; - seen.add(key); - registrations.push({ - providerID: variant.providerID, - modelID: variant.modelID, - variant, - }); - } - } - return registrations; -} - -/** Publish registrations onto real models present in the native editor. */ -export function applyVariantRegistrations( - editor: ModelEditorLike, - registrations: readonly VariantRegistration[], -): number { - let applied = 0; - for (const registration of registrations) { - // Only real provider/models are touched; missing targets are skipped, never invented. - if (editor.get(registration.providerID, registration.modelID) === undefined) continue; - editor.update(registration.providerID, registration.modelID, (model) => { - if (model.variants.some((variant) => String(variant.id) === registration.variant.id)) return; - model.variants.push({ - id: Model.VariantID.make(registration.variant.id), - ...(registration.variant.settings === undefined - ? {} - : { settings: { ...registration.variant.settings } }), - ...(registration.variant.body === undefined - ? {} - : { body: { ...registration.variant.body } }), - ...(registration.variant.headers === undefined - ? {} - : { headers: { ...registration.variant.headers } }), - }); - applied += 1; - }); - } - return applied; -} -// END_BLOCK_VARIANT_REGISTRY - -// START_BLOCK_AGENT_POLICY -function selectionWithVariant( - selection: ModelSelection, - registrations: readonly VariantRegistration[], -): ModelSelection { - if (selection.variant !== undefined) { - const sourceMatch = registrations.find( - (registration) => - registration.providerID === selection.providerID && - registration.modelID === selection.modelID && - registration.variant.sourceVariant === selection.variant, - ); - return sourceMatch === undefined - ? selection - : { ...selection, variant: sourceMatch.variant.id }; - } - const match = registrations.find( - (registration) => - registration.providerID === selection.providerID && - registration.modelID === selection.modelID, - ); - return match === undefined ? selection : { ...selection, variant: match.variant.id }; -} - -/** Apply snapshot-bound agent selections without overwriting explicit literal models. */ +/** Apply role-policy agent selections without overwriting explicit literal models. */ export function applyAgentPolicies( editor: AgentEditorLike, bindings: readonly AgentPolicyBinding[], - registrations: readonly VariantRegistration[] = [], ): number { let applied = 0; for (const binding of bindings) { - const captured = binding.selection; - if (captured === undefined) continue; + const selection: ModelSelection | undefined = binding.selection; + if (selection === undefined) continue; const target = editor.get(binding.agentID); - // A user-supplied literal model wins; only unset agents adopt the snapshot policy. + // A user-supplied literal model wins; only unset agents adopt the policy. if (target === undefined || target.model !== undefined) continue; editor.update(binding.agentID, (agent) => { if (agent.model !== undefined) return; - const selection = selectionWithVariant(captured, registrations); agent.model = { id: Model.ID.make(selection.modelID), providerID: Provider.ID.make(selection.providerID), @@ -267,4 +47,3 @@ export function applyAgentPolicies( } return applied; } -// END_BLOCK_AGENT_POLICY diff --git a/src/runtime/session-settings.test.ts b/src/runtime/session-settings.test.ts new file mode 100644 index 00000000..6703ce26 --- /dev/null +++ b/src/runtime/session-settings.test.ts @@ -0,0 +1,317 @@ +// FILE: src/runtime/session-settings.test.ts +// VERSION: 1.0.0 +// START_MODULE_CONTRACT +// PURPOSE: Verify the session-owned settings record codec, config birth values, live-config projection, RMW metadata merging with foreign-key preservation, lineage resolution guards, and the one-shot legacy-binding migration. +// SCOPE: Deterministic in-memory fixtures only; no real host, filesystem data dir, or network. +// DEPENDS: [bun:test, src/runtime/session-settings.js, src/lib/vvoc-config.js] +// LINKS: [M-NATIVE-RUNTIME, V-M-NATIVE-RUNTIME] +// ROLE: TEST +// MAP_MODE: LOCALS +// END_MODULE_CONTRACT +// +// START_MODULE_MAP +// baseConfig - Default vvoc config fixture with deterministic roles and toggles. +// END_MODULE_MAP +// +// START_CHANGE_SUMMARY +// LAST_CHANGE: [C-SESSION-OWNED-SETTINGS T-001 - Covered the settings record, projection, RMW merge, lineage, and migration.] +// END_CHANGE_SUMMARY + +import { describe, expect, test } from "bun:test"; +import { createDefaultVvocConfig, type VvocConfig } from "../lib/vvoc-config.js"; +import type { NativeSessionView } from "./types.js"; +import { + decodeFamilySettings, + FAMILY_SETTINGS_VERSION, + INTERNAL_PRIMARY_AGENTS, + isFamilyRootView, + mergeSettingsIntoMetadata, + migrateFamilyBindings, + projectFamilyConfig, + readSettingsFromMetadata, + resolveSessionFamily, + SESSION_SETTINGS_METADATA_KEY, + SessionLineageError, + settingsFromConfig, + settingsId, + type LegacyBindingsReader, +} from "./session-settings.js"; + +function baseConfig(): VvocConfig { + const config = createDefaultVvocConfig(); + config.roles = { smart: "prov/m-smart", fast: "prov/m-fast" }; + config.orchestration = { profile: "orchestrated" }; + config.plugins = { ...config.plugins, workflow: false, "peak-hours": { enabled: true } }; + return config; +} + +describe("decodeFamilySettings", () => { + test("accepts a valid record and normalizes the preset", () => { + const decoded = decodeFamilySettings({ + version: 1, + preset: " vv-codex ", + roles: { smart: "prov/m1" }, + profile: "balanced", + plugins: { workflow: false }, + }); + expect(decoded.error).toBeUndefined(); + expect(decoded.settings).toEqual({ + version: 1, + preset: "vv-codex", + roles: { smart: "prov/m1" }, + profile: "balanced", + plugins: { workflow: false }, + }); + }); + + test("rejects wrong version, malformed maps, unknown profiles, and bad presets", () => { + expect(decodeFamilySettings({}).error).toBeDefined(); + expect(decodeFamilySettings({ version: 2 }).error).toBeDefined(); + expect( + decodeFamilySettings({ + version: FAMILY_SETTINGS_VERSION, + roles: { r: "" }, + profile: "balanced", + plugins: {}, + }).error, + ).toBeDefined(); + expect( + decodeFamilySettings({ + version: FAMILY_SETTINGS_VERSION, + roles: {}, + profile: "no-such-profile", + plugins: {}, + }).error, + ).toBeDefined(); + expect( + decodeFamilySettings({ + version: FAMILY_SETTINGS_VERSION, + roles: {}, + profile: "balanced", + plugins: { workflow: "yes" }, + }).error, + ).toBeDefined(); + expect( + decodeFamilySettings({ + version: FAMILY_SETTINGS_VERSION, + roles: {}, + profile: "balanced", + plugins: {}, + preset: " ", + }).error, + ).toBeDefined(); + }); +}); + +describe("settingsFromConfig and projection", () => { + test("resolves toggles, roles, and profile from the effective config", () => { + const settings = settingsFromConfig(baseConfig()); + expect(settings.version).toBe(1); + expect(settings.profile).toBe("orchestrated"); + expect(settings.roles).toEqual({ smart: "prov/m-smart", fast: "prov/m-fast" }); + expect(settings.plugins.workflow).toBe(false); + expect(settings.plugins["peak-hours"]).toBe(true); + expect(settings.preset).toBeUndefined(); + }); + + test("projection keeps live detail and credentials while settings govern toggles", () => { + const settings = settingsFromConfig(baseConfig()); + const live = baseConfig(); + live.roles = { smart: "prov/m-new" }; + live.orchestration = { profile: "single-session" }; + live.plugins = { + ...live.plugins, + workflow: true, + "peak-hours": { enabled: false, schedules: { p: { windows: [] } } }, + }; + const projected = projectFamilyConfig(settings, live); + // Family-governed fields come from the settings record. + expect(projected.roles).toEqual({ smart: "prov/m-smart", fast: "prov/m-fast" }); + expect(projected.orchestration).toEqual({ profile: "orchestrated" }); + expect(projected.plugins.workflow).toBe(false); + // Resolved-enabled overrides a later live disable while live detail survives. + expect(projected.plugins["peak-hours"]).toEqual({ + enabled: true, + schedules: { p: { windows: [] } }, + }); + // Credential-bearing sections pass through from live untouched. + expect(projected.web).toEqual(live.web); + }); + + test("projection does not mutate the live config", () => { + const settings = settingsFromConfig(baseConfig()); + const live = baseConfig(); + const before = structuredClone(live); + projectFamilyConfig(settings, live); + expect(live).toEqual(before); + }); + + test("settingsId is stable and differs across values", () => { + const a = settingsFromConfig(baseConfig()); + expect(settingsId(a)).toBe(settingsId(a)); + const flipped = { ...a, profile: "balanced" as const }; + expect(settingsId(a)).not.toBe(settingsId(flipped)); + }); +}); + +describe("read-modify-write merge", () => { + test("merge sets only the settings key and preserves foreign keys", () => { + const foreign = { user: { note: "keep me" }, count: 3 }; + const settings = settingsFromConfig(baseConfig()); + const merged = mergeSettingsIntoMetadata(foreign, settings); + expect(merged.user).toEqual({ note: "keep me" }); + expect(merged.count).toBe(3); + expect(merged[SESSION_SETTINGS_METADATA_KEY]).toEqual(settings); + }); + + test("merge tolerates absent metadata and readSettingsFromMetadata round-trips", () => { + const settings = settingsFromConfig(baseConfig()); + const merged = mergeSettingsIntoMetadata(undefined, settings); + const read = readSettingsFromMetadata(merged); + expect(read && "settings" in read ? read.settings : undefined).toEqual(settings); + expect(readSettingsFromMetadata(undefined)).toBeUndefined(); + const missing = readSettingsFromMetadata({ other: 1 }); + expect(missing).toBeUndefined(); + const broken = readSettingsFromMetadata({ [SESSION_SETTINGS_METADATA_KEY]: { version: 9 } }); + expect(broken && "error" in broken ? broken.error : undefined).toBeDefined(); + }); +}); + +describe("lineage resolution", () => { + const sessions = new Map([ + ["ses_root", { id: "ses_root", locationDirectory: "/w" }], + ["ses_child", { id: "ses_child", parentID: "ses_root" }], + ["ses_grand", { id: "ses_grand", forkSessionID: "ses_child" }], + [ + "ses_aux", + { id: "ses_aux", parentID: "ses_root", metadata: { vvocAuxiliary: { kind: "title" } } }, + ], + ]); + const readSession = async (sessionID: string) => sessions.get(sessionID); + + test("walks parentID and fork links to the root", async () => { + await expect(resolveSessionFamily({ sessionID: "ses_grand", readSession })).resolves.toBe( + "ses_root", + ); + await expect(resolveSessionFamily({ sessionID: "ses_child", readSession })).resolves.toBe( + "ses_root", + ); + await expect(resolveSessionFamily({ sessionID: "ses_root", readSession })).resolves.toBe( + "ses_root", + ); + }); + + test("refuses cycles and missing sessions", async () => { + const cyclic = new Map([ + ["a", { id: "a", parentID: "b" }], + ["b", { id: "b", parentID: "a" }], + ]); + await expect( + resolveSessionFamily({ sessionID: "a", readSession: async (id) => cyclic.get(id) }), + ).rejects.toBeInstanceOf(SessionLineageError); + await expect( + resolveSessionFamily({ sessionID: "ses_ghost", readSession }), + ).rejects.toBeInstanceOf(SessionLineageError); + }); + + test("birthable roots exclude parented sessions and internal agents", () => { + expect(isFamilyRootView(sessions.get("ses_root")!)).toBe(true); + expect(isFamilyRootView(sessions.get("ses_child")!)).toBe(false); + expect(isFamilyRootView({ id: "t", agent: "title" })).toBe(false); + expect(isFamilyRootView({ id: "s", agent: "summary" })).toBe(false); + expect(isFamilyRootView({ id: "c", agent: "compaction" })).toBe(false); + expect(INTERNAL_PRIMARY_AGENTS.has("title")).toBe(true); + }); +}); + +describe("legacy binding migration", () => { + function fixtureReader( + families: readonly { familyId: string; snapshotHash: string }[], + snapshots: Readonly>, + ): LegacyBindingsReader & { prune(): Promise; readonly pruneCalls: number } { + let pruneCalls = 0; + return { + async listFamilies() { + return families; + }, + async readSnapshot(hash) { + return snapshots[hash]; + }, + get pruneCalls() { + return pruneCalls; + }, + async prune() { + pruneCalls += 1; + }, + }; + } + + const policy = { + roles: { smart: "prov/m1" }, + orchestration: { profile: "orchestrated" }, + plugins: { workflow: false }, + }; + + test("converts live roots, skips dead roots and corrupt snapshots, prunes on success", async () => { + const reader = fixtureReader( + [ + { familyId: "ses_live", snapshotHash: "h1" }, + { familyId: "ses_dead", snapshotHash: "h2" }, + { familyId: "ses_corrupt", snapshotHash: "h3" }, + ], + { h1: { policy }, h3: "not-an-object" }, + ); + const writes: Array<{ sessionID: string; settings: unknown }> = []; + const outcome = await migrateFamilyBindings({ + reader, + readSession: async (id) => (id === "ses_live" ? { id } : undefined), + writeSettings: async (sessionID, settings) => { + writes.push({ sessionID, settings }); + }, + }); + expect(outcome.converted).toEqual(["ses_live"]); + expect([...outcome.skipped].sort()).toEqual(["ses_corrupt", "ses_dead"]); + expect(outcome.failed).toEqual([]); + expect(outcome.pruned).toBe(true); + expect(reader.pruneCalls).toBe(1); + expect(writes).toHaveLength(1); + expect(writes[0].sessionID).toBe("ses_live"); + // The migrated record carries the fully resolved toggle map, roles, and profile. + expect(writes[0].settings).toMatchObject({ + version: 1, + roles: { smart: "prov/m1" }, + profile: "orchestrated", + plugins: { workflow: false, analytics: true }, + }); + }); + + test("a write failure keeps directories and reports the family as failed", async () => { + const reader = fixtureReader([{ familyId: "ses_live", snapshotHash: "h1" }], { + h1: { policy }, + }); + const outcome = await migrateFamilyBindings({ + reader, + readSession: async (id) => (id === "ses_live" ? { id } : undefined), + writeSettings: async () => { + throw new Error("host write failed"); + }, + }); + expect(outcome.converted).toEqual([]); + expect(outcome.failed).toEqual(["ses_live"]); + expect(outcome.pruned).toBe(false); + expect(reader.pruneCalls).toBe(0); + }); + + test("a non-root legacy family id is skipped", async () => { + const reader = fixtureReader([{ familyId: "ses_child", snapshotHash: "h1" }], { + h1: { policy }, + }); + const outcome = await migrateFamilyBindings({ + reader, + readSession: async (id) => (id === "ses_child" ? { id, parentID: "ses_root" } : undefined), + writeSettings: async () => undefined, + }); + expect(outcome.skipped).toEqual(["ses_child"]); + expect(outcome.pruned).toBe(true); + }); +}); diff --git a/src/runtime/session-settings.ts b/src/runtime/session-settings.ts new file mode 100644 index 00000000..f771e12b --- /dev/null +++ b/src/runtime/session-settings.ts @@ -0,0 +1,601 @@ +// FILE: src/runtime/session-settings.ts +// VERSION: 1.0.0 +// START_MODULE_CONTRACT +// PURPOSE: Own the session-owned family settings record stored in native session metadata: the narrow resolved record format, strict decode, birth from the effective configuration, family-root lineage resolution, read-modify-write metadata merging, the projected consumer view with live credentials, and the one-shot legacy-binding migration. +// SCOPE: Pure record/projection/lineage helpers plus the file-backed legacy bindings reader and migration converter. Family settings are resolved values only (roles, orchestration profile, plugin toggle booleans, optional preset name) and never carry credential values. No host session access of its own: callers supply session reads and metadata writes, so this module never talks to the host client and never persists anything except through injected write callbacks. +// DEPENDS: [node:crypto, node:fs/promises, node:path, src/lib/orchestration.ts, src/lib/plugin-toggle-config.ts, src/lib/vvoc-config.ts, src/lib/vvoc-paths.ts, src/runtime/types.ts] +// LINKS: [M-NATIVE-RUNTIME, V-M-NATIVE-RUNTIME] +// ROLE: RUNTIME +// MAP_MODE: EXPORTS +// END_MODULE_CONTRACT +// +// START_MODULE_MAP +// SESSION_SETTINGS_METADATA_KEY - Native session metadata key holding the family settings record. +// FAMILY_SETTINGS_VERSION - Record schema version this module reads and writes. +// MAX_LINEAGE_HOPS - Bound on host-verified parent/fork lineage traversal. +// INTERNAL_PRIMARY_AGENTS - Host-internal primary agents whose work never births family settings. +// FamilySettings - Narrow resolved settings record persisted on a family root. +// FamilySettingsView - Consumer-facing resolution: settings plus projected config and role models. +// FamilySettingsSource - Whether a resolution came from root metadata or the live configuration. +// SessionLineageError - Lineage could not be resolved safely, so no settings were resolved. +// decodeFamilySettings - Strictly decode an unknown metadata value into a settings record. +// settingsFromConfig - Resolve the current effective configuration into a settings record. +// projectFamilyConfig - Project settings over the live configuration for consumers. +// settingsId - Deterministic short content identity of a settings record. +// readSettingsFromMetadata - Read and decode the settings record from one metadata map. +// mergeSettingsIntoMetadata - Copy a metadata map and set only the settings key (RMW merge). +// resolveSessionFamily - Resolve a session's family root through host-verified lineage. +// isFamilyRootView - True when a session view is a birthable family root. +// FamilySettingsService - Narrow settings resolution service consumed by native plugins. +// SessionSettingsClient - Structural client seam for out-of-process settings changes. +// resolveFamilyRootViaClient - Walk a session's lineage to its family root through the client seam. +// updateFamilySettingsViaClient - Read-modify-write the settings key of one family root through the client seam. +// LegacyFamilyBinding - One legacy family-binding pointer read from the bindings scope. +// LegacyBindingsReader - Injectable reader over the legacy bindings and snapshot directories. +// MigrationOutcome - Bounded migration summary for one scope. +// migrateFamilyBindings - Convert legacy family bindings into root metadata, then prune legacy directories on full success. +// fileLegacyBindingsReader - Default file-backed legacy reader plus its directory pruning. +// END_MODULE_MAP +// +// START_CHANGE_SUMMARY +// LAST_CHANGE: [C-SESSION-OWNED-SETTINGS T-001 - Added the session-owned family settings record, lineage resolver, RMW metadata merge, projected consumer view, and the one-shot legacy-binding migration.] +// END_CHANGE_SUMMARY + +import { createHash } from "node:crypto"; +import { readdir, readFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { + createOrchestrationConfig, + parseOrchestrationProfile, + type OrchestrationProfile, +} from "../lib/orchestration.js"; +import { isPluginEnabled, PLUGIN_TOGGLE_NAMES } from "../lib/plugin-toggle-config.js"; +import type { VvocConfig } from "../lib/vvoc-config.js"; +import { getGlobalVvocDataDir } from "../lib/vvoc-paths.js"; +import type { NativeSessionView } from "./types.js"; + +/** Native session metadata key holding the family settings record. */ +export const SESSION_SETTINGS_METADATA_KEY = "vvoc:settings"; + +/** Record schema version this module reads and writes. */ +export const FAMILY_SETTINGS_VERSION = 1 as const; + +/** Bound on host-verified parent/fork lineage traversal. */ +export const MAX_LINEAGE_HOPS = 64; + +/** + * Host-internal primary agents whose sessions exist only to serve host + * housekeeping. Their context work never births or resolves family settings; + * they are never family members with their own behavior. + */ +export const INTERNAL_PRIMARY_AGENTS: ReadonlySet = new Set([ + "title", + "summary", + "compaction", +]); + +/** Narrow resolved settings record persisted on a family root session. */ +export interface FamilySettings { + readonly version: typeof FAMILY_SETTINGS_VERSION; + /** Name of the preset explicitly applied to this family, when one was. */ + readonly preset?: string | undefined; + /** Resolved role-id to canonical model-selection strings. */ + readonly roles: Readonly>; + /** Resolved orchestration profile name. */ + readonly profile: OrchestrationProfile; + /** Resolved plugin toggle booleans for the vvoc-managed plugin names. */ + readonly plugins: Readonly>; +} + +/** Whether a resolution came from root metadata or fell back to the live configuration. */ +export type FamilySettingsSource = "metadata" | "config"; + +/** Consumer-facing resolution for one session's family. */ +export interface FamilySettingsView { + /** Family root session id; also the metadata holder. */ + readonly familyId: string; + /** Directory of the family root session. */ + readonly locationDirectory: string | undefined; + /** Native model of the family root session, when known. */ + readonly rootModel?: import("./types.js").ModelSelection | undefined; + /** The resolved narrow settings record. */ + readonly settings: FamilySettings; + /** Family settings projected over the live effective configuration (credentials only from live). */ + readonly vvoc: VvocConfig; + /** Concrete selections parsed from the settings roles. */ + readonly roleModels: Readonly>; + /** Where the settings came from. */ + readonly source: FamilySettingsSource; + /** Deterministic short content identity of the settings record. */ + readonly settingsId: string; +} + +/** Lineage could not be resolved safely, so no settings were resolved. */ +export class SessionLineageError extends Error { + readonly code = "SESSION_LINEAGE_INVALID"; + + constructor(message: string) { + super(message); + this.name = "SessionLineageError"; + } +} + +// START_BLOCK_RECORD_CODEC +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isRoleMap(value: unknown): value is Record { + if (!isRecord(value)) return false; + for (const [key, entry] of Object.entries(value)) { + if (key.length === 0 || typeof entry !== "string" || entry.trim().length === 0) return false; + } + return true; +} + +function isToggleMap(value: unknown): value is Record { + if (!isRecord(value)) return false; + for (const [key, entry] of Object.entries(value)) { + if (key.length === 0 || typeof entry !== "boolean") return false; + } + return true; +} + +/** + * Strictly decode an unknown metadata value into a settings record. Any shape + * mismatch, unknown version, or invalid profile yields undefined so callers can + * fail soft to the live configuration; the reason is returned for bounded + * diagnostics. + */ +export function decodeFamilySettings(value: unknown): + | { readonly settings: FamilySettings; readonly error?: undefined } + | { + readonly settings?: undefined; + readonly error: string; + } { + if (!isRecord(value)) return { error: "settings record is not an object" }; + if (value.version !== FAMILY_SETTINGS_VERSION) { + return { error: `unsupported settings version ${String(value.version)}` }; + } + if (!isRoleMap(value.roles)) return { error: "roles are not a nonempty-string map" }; + if (!isToggleMap(value.plugins)) return { error: "plugins are not a boolean map" }; + let profile: OrchestrationProfile; + try { + profile = parseOrchestrationProfile(value.profile, "decode family settings"); + } catch { + return { error: `unknown orchestration profile ${String(value.profile)}` }; + } + const preset = value.preset; + if (preset !== undefined && (typeof preset !== "string" || preset.trim().length === 0)) { + return { error: "preset is present but not a nonempty string" }; + } + return { + settings: { + version: FAMILY_SETTINGS_VERSION, + roles: { ...value.roles }, + profile, + plugins: { ...value.plugins }, + ...(preset === undefined ? {} : { preset: preset.trim() }), + }, + }; +} + +/** Resolve the current effective configuration into a settings record. */ +export function settingsFromConfig(config: VvocConfig): FamilySettings { + return { + version: FAMILY_SETTINGS_VERSION, + roles: { ...config.roles }, + profile: createOrchestrationConfig(config.orchestration).profile, + plugins: Object.fromEntries( + PLUGIN_TOGGLE_NAMES.map((name) => [name, isPluginEnabled(config, name)]), + ), + }; +} + +function sortJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortJson); + if (value === null || typeof value !== "object") return value; + const record = value as Record; + const sorted: Record = {}; + for (const key of Object.keys(record).sort()) sorted[key] = sortJson(record[key]); + return sorted; +} + +/** Deterministic short content identity of a settings record. */ +export function settingsId(settings: FamilySettings): string { + return createHash("sha256") + .update(JSON.stringify(sortJson(settings))) + .digest("hex") + .slice(0, 16); +} + +/** Read and decode the settings record from one metadata map. */ +export function readSettingsFromMetadata( + metadata: Readonly> | undefined, +): { readonly settings: FamilySettings } | { readonly error: string } | undefined { + if (metadata === undefined) return undefined; + const raw = metadata[SESSION_SETTINGS_METADATA_KEY]; + if (raw === undefined) return undefined; + const decoded = decodeFamilySettings(raw); + return decoded.error === undefined ? { settings: decoded.settings } : { error: decoded.error }; +} + +/** + * Copy a metadata map and set only the settings key, leaving every foreign key + * untouched. This is the merge half of every read-modify-write: callers read + * the host's full metadata map, merge through here, and write the result back + * because the native write replaces the whole map. + */ +export function mergeSettingsIntoMetadata( + metadata: Readonly> | undefined, + settings: FamilySettings, +): Record { + const merged: Record = { ...metadata }; + merged[SESSION_SETTINGS_METADATA_KEY] = settings; + return merged; +} +// END_BLOCK_RECORD_CODEC + +// START_BLOCK_PROJECTION +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Project family settings over the live effective configuration for plugin + * consumers. The live document is deep-cloned and only the family-governed + * fields are overridden: roles, orchestration profile, and plugin toggle + * booleans. Detail inside object-valued plugin entries (schedules, modes, + * budgets) and every credential keep coming from the live configuration. + */ +export function projectFamilyConfig(settings: FamilySettings, live: VvocConfig): VvocConfig { + const projected = structuredClone(live); + projected.roles = { ...settings.roles }; + projected.orchestration = { profile: settings.profile }; + const livePlugins = isPlainRecord(projected.plugins) ? { ...projected.plugins } : {}; + for (const [name, enabled] of Object.entries(settings.plugins)) { + const liveEntry = livePlugins[name]; + if (enabled === false) { + livePlugins[name] = false; + continue; + } + // Resolved enabled wins over a later live disable while preserving live detail. + if (liveEntry === undefined || typeof liveEntry === "boolean") { + livePlugins[name] = true; + } else if (isPlainRecord(liveEntry)) { + livePlugins[name] = { ...liveEntry, enabled: true }; + } + } + projected.plugins = livePlugins as VvocConfig["plugins"]; + return projected; +} +// END_BLOCK_PROJECTION + +// START_BLOCK_LINEAGE_RESOLUTION +/** Resolve a session's family root through host-verified lineage. */ +export async function resolveSessionFamily(input: { + readonly sessionID: string; + readSession(sessionID: string): Promise; +}): Promise { + const seen = new Set(); + let current = input.sessionID; + for (let hop = 0; hop < MAX_LINEAGE_HOPS; hop += 1) { + if (seen.has(current)) { + throw new SessionLineageError("Session lineage contains a cycle; refusing to resolve."); + } + seen.add(current); + const view = await input.readSession(current); + if (view === undefined) { + throw new SessionLineageError(`Session ${current} could not be read from the host.`); + } + const parent = + typeof view.parentID === "string" && view.parentID.length > 0 + ? view.parentID + : typeof view.forkSessionID === "string" && view.forkSessionID.length > 0 + ? view.forkSessionID + : undefined; + if (parent === undefined) return current; + current = parent; + } + throw new SessionLineageError("Session lineage exceeds the supported depth."); +} + +/** + * True when a session view is a birthable family root: no parent, no fork + * source, and not a host-internal primary agent session. + */ +export function isFamilyRootView(view: NativeSessionView): boolean { + const hasParent = + (view.parentID !== undefined && view.parentID.length > 0) || + (view.forkSessionID !== undefined && view.forkSessionID.length > 0); + if (hasParent) return false; + return !(view.agent !== undefined && INTERNAL_PRIMARY_AGENTS.has(view.agent)); +} +// END_BLOCK_LINEAGE_RESOLUTION + +/** + * Narrow settings resolution service consumed by native plugins. Resolution is + * fail-soft: a session whose lineage cannot be resolved yields undefined + * instead of failing the caller's request path. + */ +export interface FamilySettingsService { + /** Family root session id for a session (host-verified lineage). */ + familyOf(sessionID: string): Promise; + /** Resolve settings for a session's family, birthing root records lazily. */ + resolve(sessionID: string): Promise; + /** Consumer-facing alias of {@link resolve}. */ + configFor(sessionID: string): Promise; + /** Release service-owned caches; never stops the host. */ + dispose(): void; +} + +// START_BLOCK_CLIENT_UPDATE +/** + * Structural client seam for out-of-process settings changes (CLI and TUI). + * The real authenticated `OpenCodeClient` satisfies it; results are decoded at + * the checked boundary because the generated parameter types are not faithful + * wire shapes. + */ +export interface SessionSettingsClient { + readonly session: { + get(input: { readonly sessionID: string }): Promise; + update(input: { + readonly sessionID: string; + readonly title?: string | undefined; + readonly metadata?: unknown; + }): Promise; + }; +} + +function decodeClientSession(value: unknown): NativeSessionView | undefined { + if (!isRecord(value)) return undefined; + const data = isRecord(value.data) ? value.data : value; + const id = data.id; + if (typeof id !== "string" || id.length === 0) return undefined; + const parentID = typeof data.parentID === "string" ? data.parentID : undefined; + const forkRaw = data.fork; + const forkSessionID = + typeof data.forkSessionID === "string" + ? data.forkSessionID + : isRecord(forkRaw) && typeof forkRaw.sessionID === "string" + ? forkRaw.sessionID + : undefined; + const modelRaw = data.model; + const model = + isRecord(modelRaw) && typeof modelRaw.id === "string" && typeof modelRaw.providerID === "string" + ? { + providerID: modelRaw.providerID, + modelID: modelRaw.id, + ...(typeof modelRaw.variant === "string" ? { variant: modelRaw.variant } : {}), + } + : undefined; + const agent = typeof data.agent === "string" ? data.agent : undefined; + const metadata = isRecord(data.metadata) ? data.metadata : undefined; + return { + id, + ...(parentID === undefined ? {} : { parentID }), + ...(forkSessionID === undefined ? {} : { forkSessionID }), + ...(agent === undefined ? {} : { agent }), + ...(metadata === undefined ? {} : { metadata }), + ...(model === undefined ? {} : { model }), + }; +} + +/** + * Walk a session's lineage to its family root through the client seam. The + * walk mirrors the runtime resolver: parentID first, then fork source, with + * the same cycle and depth guards. + */ +export async function resolveFamilyRootViaClient( + client: SessionSettingsClient, + sessionID: string, +): Promise { + const seen = new Set(); + let current = sessionID; + let view: NativeSessionView | undefined; + for (let hop = 0; hop < MAX_LINEAGE_HOPS; hop += 1) { + if (seen.has(current)) { + throw new SessionLineageError("Session lineage contains a cycle; refusing to update."); + } + seen.add(current); + view = decodeClientSession(await client.session.get({ sessionID: current })); + if (view === undefined) { + throw new SessionLineageError(`Session ${current} could not be read from the host.`); + } + const parent = + typeof view.parentID === "string" && view.parentID.length > 0 + ? view.parentID + : typeof view.forkSessionID === "string" && view.forkSessionID.length > 0 + ? view.forkSessionID + : undefined; + if (parent === undefined) return view; + current = parent; + } + throw new SessionLineageError("Session lineage exceeds the supported depth."); +} + +/** + * Read-modify-write the settings key of one session's family root through the + * client seam. The full metadata map is re-read immediately before the write + * and merged through {@link mergeSettingsIntoMetadata}, so foreign keys survive + * and only the named family's root is touched. `apply` receives the root's + * current settings (or undefined) and returns the next record. + */ +export async function updateFamilySettingsViaClient(input: { + readonly client: SessionSettingsClient; + readonly sessionID: string; + apply(current: FamilySettings | undefined): FamilySettings | Promise; +}): Promise<{ readonly rootID: string; readonly settings: FamilySettings }> { + const root = await resolveFamilyRootViaClient(input.client, input.sessionID); + const current = readSettingsFromMetadata(root.metadata); + if (current !== undefined && "error" in current) { + throw new Error(`Family settings for ${root.id} are invalid: ${current.error}`); + } + const next = await input.apply(current === undefined ? undefined : current.settings); + const updated = decodeClientSession(await input.client.session.get({ sessionID: root.id })); + const metadata = mergeSettingsIntoMetadata(updated?.metadata, next); + await input.client.session.update({ sessionID: root.id, metadata }); + return { rootID: root.id, settings: next }; +} +// END_BLOCK_CLIENT_UPDATE + +// START_BLOCK_MIGRATION +/** One legacy family-binding pointer read from a bindings scope. */ +export interface LegacyFamilyBinding { + readonly familyId: string; + readonly snapshotHash: string; +} + +/** + * Injectable reader over the legacy bindings and content-addressed snapshot + * directories. The default implementation reads the project-scoped directories + * under the global vvoc data dir. + */ +export interface LegacyBindingsReader { + listFamilies(): Promise; + /** Decoded legacy behaviour-projection snapshot, or undefined when unreadable. */ + readSnapshot(hash: string): Promise; +} + +/** Bounded migration summary for one scope. */ +export interface MigrationOutcome { + /** Families whose settings were written into live root metadata. */ + readonly converted: readonly string[]; + /** Families with nothing convertible (dead root, unreadable or invalid snapshot). */ + readonly skipped: readonly string[]; + /** Families whose metadata write failed; legacy directories are kept for retry. */ + readonly failed: readonly string[]; + /** Whether the legacy directories were removed after a fully successful pass. */ + readonly pruned: boolean; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Convert legacy family bindings into root metadata and prune the legacy + * directories only after every convertible family was written. Dead roots and + * unreadable legacy snapshots are skipped (there is nothing to convert), while + * a metadata write failure keeps the directories so the next start retries. + * The conversion is idempotent: rewriting the same resolved settings is a + * no-op, and a completed pass leaves no directories to migrate again. + */ +export async function migrateFamilyBindings(deps: { + readonly reader: LegacyBindingsReader; + readSession(sessionID: string): Promise; + /** Read-modify-write the settings key into the root's metadata. */ + writeSettings(sessionID: string, settings: FamilySettings): Promise; +}): Promise { + const converted: string[] = []; + const skipped: string[] = []; + const failed: string[] = []; + + let families: readonly LegacyFamilyBinding[]; + try { + families = await deps.reader.listFamilies(); + } catch { + // No legacy scope readable: migration is a no-op and nothing is pruned. + return { converted, skipped, failed, pruned: false }; + } + + for (const family of families) { + try { + let root: NativeSessionView | undefined; + try { + root = await deps.readSession(family.familyId); + } catch { + // A host read failure means the family root is gone: nothing to convert. + root = undefined; + } + if (root === undefined || !isFamilyRootView(root)) { + skipped.push(family.familyId); + continue; + } + const snapshot = await deps.reader.readSnapshot(family.snapshotHash); + if (!isPlainObject(snapshot) || !isPlainObject(snapshot.policy)) { + skipped.push(family.familyId); + continue; + } + await deps.writeSettings( + family.familyId, + settingsFromConfig(snapshot.policy as unknown as VvocConfig), + ); + converted.push(family.familyId); + } catch { + failed.push(family.familyId); + } + } + + const pruned = failed.length === 0; + if (pruned) await pruneLegacyDirs(deps.reader); + return { converted, skipped, failed, pruned }; +} + +/** Default file-backed legacy reader plus its directory pruning. */ +export function fileLegacyBindingsReader(paths: { + readonly bindingsDir?: string | undefined; + readonly snapshotsDir?: string | undefined; + readonly rebindDir?: string | undefined; + readonly scopeKey: string; +}): LegacyBindingsReader & { prune(): Promise } { + const dataDir = getGlobalVvocDataDir(); + // Scope hashing mirrors the legacy binding-store layout. + const scope = createHash("sha256").update(paths.scopeKey).digest("hex").slice(0, 32); + const bindingsDir = paths.bindingsDir ?? join(dataDir, "bindings", scope); + const snapshotsDir = paths.snapshotsDir ?? join(dataDir, "snapshots", scope); + const rebindDir = paths.rebindDir ?? join(dataDir, "rebind"); + + const reader: LegacyBindingsReader & { prune(): Promise } = { + async listFamilies() { + let names: string[]; + try { + names = await readdir(join(bindingsDir, "families")); + } catch { + return []; + } + const families: LegacyFamilyBinding[] = []; + for (const name of names) { + if (!name.endsWith(".json")) continue; + try { + const value = JSON.parse( + await readFile(join(bindingsDir, "families", name), "utf8"), + ) as unknown; + if ( + isPlainObject(value) && + typeof value.familyId === "string" && + typeof value.snapshotHash === "string" + ) { + families.push({ familyId: value.familyId, snapshotHash: value.snapshotHash }); + } + } catch { + // Unreadable pointer: nothing to convert for it. + } + } + return families; + }, + + async readSnapshot(hash) { + // Legacy projections lived beside the pointers, inside the binding scope. + try { + return JSON.parse(await readFile(join(bindingsDir, "policies", `${hash}.json`), "utf8")); + } catch { + return undefined; + } + }, + + async prune() { + await rm(bindingsDir, { recursive: true, force: true }); + await rm(snapshotsDir, { recursive: true, force: true }); + await rm(rebindDir, { recursive: true, force: true }); + }, + }; + return reader; +} + +async function pruneLegacyDirs(reader: LegacyBindingsReader): Promise { + const prunable = reader as { prune?: (() => Promise) | undefined }; + if (typeof prunable.prune !== "function") return; + await prunable.prune(); +} +// END_BLOCK_MIGRATION diff --git a/src/runtime/snapshot-config.test.ts b/src/runtime/snapshot-config.test.ts index 9b2aaf63..40ffea51 100644 --- a/src/runtime/snapshot-config.test.ts +++ b/src/runtime/snapshot-config.test.ts @@ -1,9 +1,9 @@ // FILE: src/runtime/snapshot-config.test.ts -// VERSION: 2.0.0 +// VERSION: 3.0.0 // START_MODULE_CONTRACT -// PURPOSE: Verify full effective-config extraction, role/agent binding parsing, initial-selection provenance, and config.updated watching. -// SCOPE: vvoc snapshot policy derivation, invalid-binding failure, initial default/explicit classification, and watcher notification and error reporting; no native host. -// DEPENDS: [bun:test, src/lib/config-layers.ts, src/lib/vvoc-config.ts, src/runtime/snapshot-config.ts, src/runtime/types.ts] +// PURPOSE: Verify effective-config extraction, role/agent binding parsing, canonical selection normalization, and native event decoding. +// SCOPE: vvoc snapshot policy derivation, invalid-binding failure, canonical variant handling, and session-creation/model-selection/config-update decoding; no native host. +// DEPENDS: [bun:test, src/lib/config-layers.js, src/lib/vvoc-config.js, src/runtime/snapshot-config.js, src/runtime/types.js] // LINKS: [M-NATIVE-RUNTIME, V-M-NATIVE-RUNTIME] // ROLE: TEST // MAP_MODE: LOCALS @@ -14,27 +14,23 @@ // END_MODULE_MAP // // START_CHANGE_SUMMARY -// LAST_CHANGE: [C-OPENCODE-V2-NATIVE T-002 - Initial-selection provenance and full config coverage.] +// LAST_CHANGE: [C-SESSION-OWNED-SETTINGS T-002 - Coverage narrowed to the surviving extraction, parsing, and event-decoding helpers.] // END_CHANGE_SUMMARY import { describe, expect, test } from "bun:test"; import type { VvocConfigSnapshot } from "../lib/config-layers.js"; -import { createDefaultVvocConfig, type VvocConfig } from "../lib/vvoc-config.js"; +import { createDefaultVvocConfig } from "../lib/vvoc-config.js"; import { agentBindingsFrom, agentPolicyBindings, - behaviourContentHash, - buildBehaviourProjection, - canonicalizeJson, - classifyInitialSelection, + canonicalModelVariant, + decodeModelSelectedEvent, + decodeSessionCreatedEvent, effectiveRuntimeConfig, - isAcceptedWorkloadEvent, isConfigUpdateEvent, + normalizeModelSelection, parseRoleSelections, - sanitizePolicyConfig, - watchConfigUpdates, } from "./snapshot-config.js"; -import { SnapshotAdmissionError } from "./types.js"; function makeSnapshot(roles = {}): VvocConfigSnapshot { const config = createDefaultVvocConfig(); @@ -57,11 +53,11 @@ describe("effectiveRuntimeConfig", () => { expect(effective.sourcePath).toBe("/p/.vvoc/vvoc.json"); }); - test("parses role selections and fails closed on an invalid binding", () => { + test("parses role selections and fails loudly on an invalid binding", () => { expect(parseRoleSelections({ smart: "prov/m2#xhigh" })).toEqual({ smart: { providerID: "prov", modelID: "m2", variant: "xhigh" }, }); - expect(() => parseRoleSelections({ smart: "nope" })).toThrow(SnapshotAdmissionError); + expect(() => parseRoleSelections({ smart: "nope" })).toThrow(/invalid model binding/); }); test("derives agent bindings with resolved selections", () => { @@ -83,187 +79,49 @@ describe("effectiveRuntimeConfig", () => { }); }); -describe("classifyInitialSelection", () => { - test("a session model matching the creation default is implicit; a differing one is explicit", () => { - const creationDefault = { providerID: "loopback", modelID: "seam-smart" }; - expect( - classifyInitialSelection({ - sessionModel: { providerID: "loopback", modelID: "seam-smart" }, - creationDefault, - }), - ).toEqual({ mode: "implicit", source: "config" }); +describe("canonical selection normalization", () => { + test("the native absent/`default` variant is not a distinct selection", () => { + expect(canonicalModelVariant(undefined)).toBeUndefined(); + expect(canonicalModelVariant("default")).toBeUndefined(); + expect(canonicalModelVariant("thinking")).toBe("thinking"); expect( - classifyInitialSelection({ - sessionModel: { providerID: "loopback", modelID: "seam-fast" }, - creationDefault, - }), - ).toEqual({ - mode: "explicit", - source: "watcher", - literal: { providerID: "loopback", modelID: "seam-fast" }, - }); - expect(classifyInitialSelection({ sessionModel: undefined, creationDefault })).toEqual({ - mode: "implicit", - source: "config", - }); + normalizeModelSelection({ providerID: "prov", modelID: "m1", variant: "default" }), + ).toEqual({ providerID: "prov", modelID: "m1" }); }); +}); - test("the native absent/`default` variant is not a distinct selection", () => { +describe("event decoding", () => { + test("session.created decodes the creation model", () => { expect( - classifyInitialSelection({ - sessionModel: { providerID: "loopback", modelID: "seam-smart", variant: "default" }, - creationDefault: { providerID: "loopback", modelID: "seam-smart" }, + decodeSessionCreatedEvent({ + type: "session.created", + data: { sessionID: "ses_1", model: { id: "m1", providerID: "prov", variant: "default" } }, }), - ).toEqual({ mode: "implicit", source: "config" }); - }); - - test("only config.updated invalidates candidates; own reload output never does", () => { - expect(isConfigUpdateEvent({ type: "config.updated" })).toBe(true); - expect(isConfigUpdateEvent({ type: "model.updated" })).toBe(false); - expect(isConfigUpdateEvent({ type: "agent.updated" })).toBe(false); - expect(isConfigUpdateEvent({ type: "session.created" })).toBe(false); - }); - - test("accepted input is the native inbox enqueue, not a resume start", () => { + ).toEqual({ sessionID: "ses_1", model: { providerID: "prov", modelID: "m1" } }); + expect(decodeSessionCreatedEvent({ type: "session.deleted" })).toBeUndefined(); expect( - isAcceptedWorkloadEvent({ type: "session.execution.started", data: { sessionID: "s" } }), - ).toBe(false); + decodeSessionCreatedEvent({ type: "session.created", data: { sessionID: "ses_1" } }), + ).toEqual({ sessionID: "ses_1" }); expect( - isAcceptedWorkloadEvent({ - type: "session.inbox.enqueued", - data: { sessionID: "s", inboxID: "i", item: { type: "user" } }, - }), - ).toBe(true); - }); -}); - -describe("watchConfigUpdates", () => { - test("notifies on config.updated, stops on abort, and reports transport failure", async () => { - const controller = new AbortController(); - let changes = 0; - const stop = watchConfigUpdates( - (async function* () { - yield { type: "session.created" }; - yield { type: "config.updated" }; - yield { type: "model.updated" }; - })(), - () => { - changes += 1; - controller.abort(); - }, - controller.signal, - ); - await new Promise((resolve) => setTimeout(resolve, 10)); - stop(); - expect(changes).toBe(1); - - const errors: unknown[] = []; - let failed = false; - const failedStop = watchConfigUpdates( - (async function* () { - yield { type: "session.created" }; - throw new Error("transport lost"); - })(), - () => undefined, - new AbortController().signal, - (error) => { - failed = true; - errors.push(error); - }, - ); - await new Promise((resolve) => setTimeout(resolve, 10)); - failedStop(); - expect(failed).toBe(true); - expect(errors).toHaveLength(1); - }); -}); - -describe("behaviour projection", () => { - function projectionConfig(overrides: { - readonly schema?: string; - readonly apiKey?: string; - readonly redactionSecret?: string; - }): VvocConfig { - const config = createDefaultVvocConfig(); - return { - ...config, - $schema: overrides.schema ?? config.$schema, - web: { - search: { provider: "exa", apiKey: overrides.apiKey ?? "k" }, - fetch: { provider: "native", apiKey: overrides.apiKey ?? "k" }, - }, - secretsRedaction: { - ...config.secretsRedaction, - secret: overrides.redactionSecret ?? "seed", - }, - }; - } - - test("strips credential values and version-derived fields", () => { - const config = projectionConfig({ - schema: "https://cdn.jsdelivr.net/npm/@osovv/vv-opencode@2.1.3/schemas/vvoc/v3.json", - apiKey: "secret-api-value", - redactionSecret: "secret-seed-value", - }); - const sanitized = sanitizePolicyConfig(config); - const serialized = JSON.stringify(sanitized); - expect(serialized).not.toContain("secret-api-value"); - expect(serialized).not.toContain("secret-seed-value"); - expect(serialized).not.toContain("$schema"); - expect(sanitized.web?.search?.apiKey).toBeUndefined(); - expect(sanitized.secretsRedaction.secret).toBeUndefined(); - expect(sanitized.roles).toEqual(config.roles); - expect(sanitized.plugins).toEqual(config.plugins); + decodeSessionCreatedEvent({ type: "session.created", data: { sessionID: 42 } }), + ).toBeUndefined(); }); - test("content hash ignores package version and credentials", () => { - const roles = { ...createDefaultVvocConfig().roles, default: "prov/m1" }; - const agentRoles = { build: "default" }; - const left = buildBehaviourProjection({ - roles, - agentRoles, - vvoc: projectionConfig({ - schema: "https://cdn.jsdelivr.net/npm/@osovv/vv-opencode@2.1.3/schemas/vvoc/v3.json", - apiKey: "left-key", - redactionSecret: "left-seed", - }), - }); - const right = buildBehaviourProjection({ - roles, - agentRoles, - vvoc: projectionConfig({ - schema: "https://cdn.jsdelivr.net/npm/@osovv/vv-opencode@9.9.9/schemas/vvoc/v3.json", - apiKey: "right-key", - redactionSecret: "right-seed", + test("model.selected decodes explicit switches only", () => { + expect( + decodeModelSelectedEvent({ + type: "session.model.selected", + data: { sessionID: "ses_1", model: { id: "m2", providerID: "prov" } }, }), - }); - expect(behaviourContentHash(left)).toBe(behaviourContentHash(right)); + ).toEqual({ sessionID: "ses_1", model: { providerID: "prov", modelID: "m2" } }); + expect( + decodeModelSelectedEvent({ type: "session.model.selected", data: { sessionID: "ses_1" } }), + ).toBeUndefined(); }); - test("content hash tracks behaviour and is deterministic across key order", () => { - const config = createDefaultVvocConfig(); - const base = buildBehaviourProjection({ - roles: config.roles, - agentRoles: { build: "default" }, - vvoc: config, - }); - const reorderedConfig = { - ...config, - roles: Object.fromEntries(Object.entries(config.roles).reverse()), - }; - const reordered = buildBehaviourProjection({ - roles: reorderedConfig.roles, - agentRoles: { build: "default" }, - vvoc: reorderedConfig, - }); - const changed = buildBehaviourProjection({ - roles: { ...config.roles, default: "prov/changed" }, - agentRoles: { build: "default" }, - vvoc: config, - }); - expect(canonicalizeJson({ b: 1, a: 2 })).toBe(canonicalizeJson({ a: 2, b: 1 })); - expect(behaviourContentHash(reordered)).toBe(behaviourContentHash(base)); - expect(behaviourContentHash(base)).toBe(behaviourContentHash(base)); - expect(behaviourContentHash(changed)).not.toBe(behaviourContentHash(base)); + test("only config.updated invalidates the cached effective config", () => { + expect(isConfigUpdateEvent({ type: "config.updated" })).toBe(true); + expect(isConfigUpdateEvent({ type: "model.updated" })).toBe(false); + expect(isConfigUpdateEvent({ type: "agent.updated" })).toBe(false); }); }); diff --git a/src/runtime/snapshot-config.ts b/src/runtime/snapshot-config.ts index 113286fe..911e5471 100644 --- a/src/runtime/snapshot-config.ts +++ b/src/runtime/snapshot-config.ts @@ -1,8 +1,8 @@ // FILE: src/runtime/snapshot-config.ts -// VERSION: 2.0.0 +// VERSION: 3.0.0 // START_MODULE_CONTRACT -// PURPOSE: Derive full effective snapshot policy from loaded vvoc configuration, native agent/model overlays, and native config updates, and classify an initial session selection as explicit or implicit without forced location reloads. -// SCOPE: Effective role/agent-binding extraction, raw custom-agent preservation, native overlay merging, initial-selection provenance from the host default, and config.updated watcher plumbing for unbound candidates. No native client, no persistence, no admission, no location reload. +// PURPOSE: Extract the effective runtime policy (roles, agent bindings, full vvoc document) from a loaded vvoc configuration and decode the native session-creation events the runtime consumes for creation-time role reconciliation. +// SCOPE: Effective role/agent-binding extraction, strict role-selection parsing, native event decoding for session creation and model selection, config-update event identification, and canonical selection normalization. No persistence, no admission, no dispatch validation, no location reload, and no credential handling. // DEPENDS: [src/lib/config-layers.ts, src/lib/model-roles.ts, src/runtime/types.ts] // LINKS: [M-NATIVE-RUNTIME, V-M-NATIVE-RUNTIME] // ROLE: RUNTIME @@ -15,38 +15,26 @@ // agentBindingsFrom - Derive built-in agent bindings from role assignments and resolved selections. // agentPolicyBindings - Derive built-in agent bindings from an effective runtime config. // deepCloneConfig - Deep-copy a config document into an immutable captured value. -// classifyInitialSelection - Classify a host session's model against the default in force at creation. -// decodeSessionCreatedEvent - Decode the session's creation model for provenance. // canonicalModelVariant - Collapse the native absent/`default` variant to undefined. // normalizeModelSelection - Normalize one selection to its canonical variant form. +// decodeSessionCreatedEvent - Decode the session's creation model for creation-time reconciliation. // decodeModelSelectedEvent - Decode a native explicit model-selection event. -// decodeInboxEnqueuedEvent - Decode a native accepted-input event (session.inbox.enqueued). -// isAcceptedWorkloadEvent - True when a native event marks an accepted workload boundary. -// isConfigUpdateEvent - True when a native config change invalidates unbound candidates. -// watchConfigUpdates - Consume config.updated events and notify the owner without a forced location reload. -// BehaviourProjection - Credential-free, version-independent projection of a bound family's policy. -// buildBehaviourProjection - Build the credential-free, version-independent behaviour projection from effective roles, agent roles, and vvoc. -// sanitizePolicyConfig - Deep-copy a config document without credential values or package-version-derived fields. -// canonicalizeJson - Deterministic JSON serialization with stable key ordering. -// behaviourContentHash - SHA-256 content hash of a behaviour projection, independent of package version. +// isConfigUpdateEvent - True when a native config change invalidates cached effective config. // END_MODULE_MAP // // START_CHANGE_SUMMARY -// LAST_CHANGE: [C-SNAPSHOT-ANCHORING-REDESIGN T-002 - Added the credential-free, version-independent behaviour projection and content hash.] +// LAST_CHANGE: [C-SESSION-OWNED-SETTINGS T-002 - Dropped the behaviour projection/hash, initial-selection provenance classification, accepted-input decoding, and config-watch plumbing; role parsing now fails with plain errors because no admission layer remains.] // END_CHANGE_SUMMARY -import { createHash } from "node:crypto"; import type { VvocConfigSnapshot } from "../lib/config-layers.js"; import type { VvocConfig } from "../lib/vvoc-config.js"; import { getBuiltInRoleBindings, parseModelSelectionWithVariant } from "../lib/model-roles.js"; -import { - SnapshotAdmissionError, - type AgentPolicyBinding, - type CaptureIntent, - type CapturedVvocConfig, - type EffectiveRuntimeConfig, - type ModelSelection, - type RuntimeEvent, +import type { + AgentPolicyBinding, + CapturedVvocConfig, + EffectiveRuntimeConfig, + ModelSelection, + RuntimeEvent, } from "./types.js"; // START_BLOCK_POLICY_EXTRACTION @@ -80,10 +68,7 @@ export function parseRoleSelections( try { parsed = parseModelSelectionWithVariant(raw); } catch (cause) { - throw new SnapshotAdmissionError( - `Role ${role} has an invalid model binding (${raw}); refusing to capture policy.`, - { cause }, - ); + throw new Error(`Role ${role} has an invalid model binding (${raw}).`, { cause }); } roleModels[role] = { providerID: parsed.provider, @@ -117,97 +102,12 @@ export function agentPolicyBindings(effective: EffectiveRuntimeConfig): AgentPol } // END_BLOCK_POLICY_EXTRACTION -// START_BLOCK_BEHAVIOUR_PROJECTION -/** A credential-free, version-independent projection of a family's bound policy. */ -export type BehaviourProjection = { - readonly roleModels: Readonly>; - readonly agentBindings: ReadonlyArray; - readonly policy: VvocConfig; -}; - -/** Credential field names that must never reach a durable artifact. */ -const CREDENTIAL_KEYS = new Set(["apiKey"]); - -/** Package-version-derived fields that must never influence snapshot identity. */ -const VERSION_DERIVED_KEYS = new Set(["$schema", "$id"]); - -function stripSensitiveKeys(value: unknown, parentKey?: string): void { - if (Array.isArray(value)) { - for (const entry of value) stripSensitiveKeys(entry, parentKey); - return; - } - if (value === null || typeof value !== "object") return; - const record = value as Record; - for (const key of Object.keys(record)) { - if (CREDENTIAL_KEYS.has(key) || VERSION_DERIVED_KEYS.has(key)) { - delete record[key]; - continue; - } - // The redaction seed is a secret value, not a policy knob. - if (parentKey === "secretsRedaction" && key === "secret") { - delete record[key]; - continue; - } - stripSensitiveKeys(record[key], key); - } -} - -/** - * Deep-copy a validated config document while dropping every credential value and - * every package-version-derived field, so the result is safe to persist and hashes - * independently of the installed package version. - */ -export function sanitizePolicyConfig(config: VvocConfig): VvocConfig { - const clone = structuredClone(config) as unknown; - stripSensitiveKeys(clone); - return clone as VvocConfig; -} - -function sortJson(value: unknown): unknown { - if (Array.isArray(value)) return value.map(sortJson); - if (value === null || typeof value !== "object") return value; - const record = value as Record; - const sorted: Record = {}; - for (const key of Object.keys(record).sort()) sorted[key] = sortJson(record[key]); - return sorted; -} - -/** Deterministic JSON serialization with stable object key ordering. */ -export function canonicalizeJson(value: unknown): string { - return JSON.stringify(sortJson(value)); -} - -/** SHA-256 content hash of a behaviour projection; identity never depends on package version. */ -export function behaviourContentHash(projection: BehaviourProjection): string { - return createHash("sha256").update(canonicalizeJson(projection)).digest("hex"); -} - -/** - * Build the behaviour-relevant projection bound to a family: resolved model - * selections for root roles and agents plus the sanitized policy document. - * Credentials and version-derived fields are excluded from both the value and the hash. - */ -export function buildBehaviourProjection(input: { - readonly roles: Readonly>; - readonly agentRoles: Readonly>; - readonly vvoc: VvocConfig; -}): BehaviourProjection { - const roleModels = parseRoleSelections(input.roles); - return { - roleModels, - agentBindings: agentBindingsFrom(input.agentRoles, roleModels), - policy: sanitizePolicyConfig(input.vvoc), - }; -} -// END_BLOCK_BEHAVIOUR_PROJECTION - -// START_BLOCK_PROVENANCE +// START_BLOCK_EVENT_DECODING /** * Collapse the native `default` variant to undefined. Native session rows store * `variant ?? "default"` (core/session/info.ts), while `Model.Resolver.withVariant` * treats `"default"` as absent and resolution omits it, so the two are the same - * logical selection. Keeping them distinct makes ordinary unqualified requests - * look like named variants and breaks equality/rollback ownership. + * logical selection. */ export function canonicalModelVariant(variant: string | undefined): string | undefined { return variant === undefined || variant === "default" ? undefined : variant; @@ -223,39 +123,6 @@ export function normalizeModelSelection(selection: ModelSelection): ModelSelecti }; } -function sameSelection( - left: ModelSelection | undefined, - right: ModelSelection | undefined, -): boolean { - if (left === undefined || right === undefined) return left === right; - return ( - left.providerID === right.providerID && - left.modelID === right.modelID && - canonicalModelVariant(left.variant) === canonicalModelVariant(right.variant) - ); -} - -/** - * Classify a host session's model at first work against the host default that was - * in force when the session was created. A session-local model that differs from - * that default is an explicit user selection; a matching model followed the - * implicit default. The default is captured at `session.created` time, not read - * later, so a preset/config switch does not turn an old implicit tab explicit. - */ -export function classifyInitialSelection(input: { - readonly sessionModel: ModelSelection | undefined; - readonly creationDefault: ModelSelection | undefined; -}): CaptureIntent { - const { sessionModel, creationDefault } = input; - if (sessionModel === undefined || creationDefault === undefined) { - return { mode: "implicit", source: "config" }; - } - if (!sameSelection(sessionModel, creationDefault)) { - return { mode: "explicit", source: "watcher", literal: sessionModel }; - } - return { mode: "implicit", source: "config" }; -} - /** Decode a native `session.created` payload carrying the session's initial model. */ export function decodeSessionCreatedEvent( event: RuntimeEvent, @@ -285,7 +152,7 @@ function decodeModelRef(value: unknown): ModelSelection | undefined { /** * Decode a native explicit model-selection event (`session.model.selected`). The * host emits it only when a switch actually changes the model; an identical - * switch is a no-op with no event, which is why provenance also reads the session. + * switch is a no-op with no event. */ export function decodeModelSelectedEvent(event: RuntimeEvent): | { @@ -304,100 +171,12 @@ export function decodeModelSelectedEvent(event: RuntimeEvent): } /** - * Decode a native accepted-input event. `session.inbox.enqueued` is the host's - * durable admission of one input (schema/session-event.ts); `execution.started` - * can be an explicit resume and is not admission on its own. - */ -export function decodeInboxEnqueuedEvent(event: RuntimeEvent): - | { - readonly sessionID: string; - readonly inboxID: string; - readonly itemType: string; - readonly created?: number | undefined; - readonly seq?: number | undefined; - } - | undefined { - if (event.type !== "session.inbox.enqueued") return undefined; - const data = event.data; - if (typeof data !== "object" || data === null) return undefined; - const record = data as Record; - const sessionID = record.sessionID; - const inboxID = record.inboxID; - if (typeof sessionID !== "string" || typeof inboxID !== "string") return undefined; - const item = record.item; - const itemType = - typeof item === "object" && - item !== null && - typeof (item as { type?: unknown }).type === "string" - ? String((item as { type: string }).type) - : "unknown"; - const created = - typeof event.created === "number" && Number.isFinite(event.created) ? event.created : undefined; - const rawSeq = event.durable?.seq; - const seq = typeof rawSeq === "number" && Number.isFinite(rawSeq) ? rawSeq : undefined; - return { - sessionID, - inboxID, - itemType, - ...(created === undefined ? {} : { created }), - ...(seq === undefined ? {} : { seq }), - }; -} - -/** - * True when the event is a native accepted-input admission: `session.inbox.enqueued` - * carrying a `user` or `synthetic` item. `session.inbox.delivered` only proves the - * projection moved, and `session.execution.started` can be an explicit resume, so - * neither alone authorizes a commit without the matching enqueued identity. - */ -export function isAcceptedWorkloadEvent(event: RuntimeEvent): boolean { - const enqueued = decodeInboxEnqueuedEvent(event); - if (enqueued === undefined) return false; - return enqueued.itemType === "user" || enqueued.itemType === "synthetic"; -} - -/** - * True when the event invalidates unbound candidates through a native config - * change. Never `model.updated`/`agent.updated`: those are emitted by the - * runtime's own `model.reload()`/`agent.reload()` and would make the refresh - * re-trigger itself in an unbounded loop. + * True when the event invalidates the cached effective configuration through a + * native config change. Never `model.updated`/`agent.updated`: those are emitted + * by the runtime's own `model.reload()`/`agent.reload()` and would make the + * refresh re-trigger itself in an unbounded loop. */ export function isConfigUpdateEvent(event: RuntimeEvent): boolean { return event.type === "config.updated"; } -// END_BLOCK_PROVENANCE - -// START_BLOCK_CONFIG_WATCH -/** - * Consume native config.updated events and notify the owner so cached effective - * configuration and transforms are rebuilt. Never triggers a forced location - * reload, and a watcher failure is reported rather than silently swallowed. - */ -export function watchConfigUpdates( - events: AsyncIterable, - onChange: () => void | Promise, - signal: AbortSignal, - onError?: (error: unknown) => void, -): () => void { - let stopped = false; - const iterator = events[Symbol.asyncIterator](); - const loop = (async () => { - try { - for (;;) { - if (stopped || signal.aborted) break; - const next = await iterator.next(); - if (next.done) break; - if (isConfigUpdateEvent(next.value)) await onChange(); - } - } catch (error) { - // A watcher failure is reported to the owner; it is never silently swallowed. - onError?.(error); - } - })(); - return () => { - stopped = true; - void loop; - void iterator.return?.(); - }; -} -// END_BLOCK_CONFIG_WATCH +// END_BLOCK_EVENT_DECODING diff --git a/src/runtime/snapshot-store.test.ts b/src/runtime/snapshot-store.test.ts deleted file mode 100644 index b5372150..00000000 --- a/src/runtime/snapshot-store.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -// FILE: src/runtime/snapshot-store.test.ts -// VERSION: 2.0.0 -// START_MODULE_CONTRACT -// PURPOSE: Verify durable project-scoped family-snapshot persistence, staged candidates, integrity verification, immutable reads, and fail-closed corruption handling. -// SCOPE: Temp-dir file-store round trips, family listing, cross-project isolation, integrity rejection, and invalid-shape rejection; no native host. -// DEPENDS: [bun:test, node:fs/promises, node:os, node:path, src/runtime/snapshot-store.ts, src/runtime/types.ts] -// LINKS: [M-NATIVE-RUNTIME, V-M-NATIVE-RUNTIME] -// ROLE: TEST -// MAP_MODE: LOCALS -// END_MODULE_CONTRACT -// -// START_MODULE_MAP -// makeCapture - Build a valid integrity-checked family capture fixture. -// tempDirs - Tracks temporary data roots for cleanup. -// createDataDir - Create a temporary vvoc data root for the store. -// END_MODULE_MAP -// -// START_CHANGE_SUMMARY -// LAST_CHANGE: [C-OPENCODE-V2-NATIVE T-002 - Project-scoped store, integrity, and immutable-read coverage.] -// END_CHANGE_SUMMARY - -import { afterEach, describe, expect, test } from "bun:test"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { buildBehaviourProjection } from "./snapshot-config.js"; -import { createDefaultVvocConfig, type VvocConfig } from "../lib/vvoc-config.js"; -import { - BINDING_STORE_DIR_NAME, - FileBindingStore, - createFileSnapshotStore, - decodeFamilyCapture, - familyCaptureIntegrity, - snapshotScopeDirName, -} from "./snapshot-store.js"; -import { - SnapshotStoreError, - stagedCandidateKey, - type FamilyCapture, - type StagedCandidate, -} from "./types.js"; - -const tempDirs: string[] = []; - -afterEach(async () => { - while (tempDirs.length > 0) { - const dir = tempDirs.pop(); - if (dir) await rm(dir, { recursive: true, force: true }); - } -}); - -async function createDataDir(): Promise { - const dir = await mkdtemp(join(tmpdir(), "vvoc-snapshot-store-")); - tempDirs.push(dir); - return dir; -} - -function makeCapture(familyId: string, overrides: Partial = {}): FamilyCapture { - const draft: FamilyCapture = { - schemaVersion: 1, - snapshotId: "", - integrity: "", - familyId, - capturedAt: 100, - location: { directory: "/project", projectID: "proj", canonical: "/project" }, - roles: { default: "prov/m1" }, - roleModels: { default: { providerID: "prov", modelID: "m1" } }, - agents: [ - { agentID: "build", role: "default", selection: { providerID: "prov", modelID: "m1" } }, - ], - variants: [], - modelSettings: [{ providerID: "prov", modelID: "m1", body: { temperature: 0 } }], - vvoc: createDefaultVvocConfig(), - intent: { mode: "implicit", source: "config" }, - ...overrides, - }; - const integrity = familyCaptureIntegrity(draft); - return { ...draft, integrity, snapshotId: integrity.slice(0, 16) }; -} - -describe("createFileSnapshotStore", () => { - test("round-trips integrity-checked captures, staged candidates, and family listing", async () => { - const store = createFileSnapshotStore({ scopeKey: "proj-1", dataDir: await createDataDir() }); - const capture = makeCapture("ses_1"); - await store.write("ses_1", capture); - - const loaded = await store.read("ses_1"); - expect(loaded).toEqual(capture); - expect(Object.isFrozen(loaded)).toBe(true); - expect(await store.list()).toEqual(["ses_1"]); - - const candidate: StagedCandidate = { - familyId: "ses_1", - sessionID: "ses_1", - stagedAt: 5, - revision: capture.integrity, - selection: { providerID: "prov", modelID: "m1" }, - capture, - }; - await store.writeCandidate(candidate); - expect(await store.readCandidates("ses_1")).toEqual([candidate]); - expect(await store.list()).toEqual(["ses_1"]); - await store.removeCandidate("ses_1", stagedCandidateKey(candidate)); - expect(await store.readCandidates("ses_1")).toEqual([]); - - expect(await store.readMarker("ses_1")).toBe(false); - await store.writeMarker("ses_1"); - expect(await store.readMarker("ses_1")).toBe(true); - await store.removeMarker("ses_1"); - expect(await store.readMarker("ses_1")).toBe(false); - - await store.remove("ses_1"); - expect(await store.read("ses_1")).toBeUndefined(); - expect(await store.list()).toEqual([]); - }); - - test("isolates captures by project scope", async () => { - const dataDir = await createDataDir(); - const left = createFileSnapshotStore({ scopeKey: "project-a", dataDir }); - const right = createFileSnapshotStore({ scopeKey: "project-b", dataDir }); - - await left.write("ses_1", makeCapture("ses_1")); - expect(await left.list()).toEqual(["ses_1"]); - expect(await right.list()).toEqual([]); - }); - - test("a moved worktree in the same project still resolves the root capture", async () => { - const dataDir = await createDataDir(); - // Two store instances with the same project scope share captures despite different paths. - const root = createFileSnapshotStore({ scopeKey: "proj-1", dataDir }); - const worktree = createFileSnapshotStore({ scopeKey: "proj-1", dataDir }); - await root.write("ses_root", makeCapture("ses_root")); - expect(await worktree.read("ses_root")).toBeDefined(); - }); - - test("fails closed on corrupt persisted state instead of adopting it", async () => { - const dataDir = await createDataDir(); - const store = createFileSnapshotStore({ scopeKey: "proj-1", dataDir }); - const path = join( - dataDir, - "snapshots", - snapshotScopeDirName("proj-1"), - `${encodeURIComponent("ses_1")}.json`, - ); - await mkdir(join(path, ".."), { recursive: true }); - await writeFile(path, "{ not json", "utf8"); - - await expect(store.read("ses_1")).rejects.toBeInstanceOf(SnapshotStoreError); - }); - - test("rejects tampered content whose integrity no longer matches", async () => { - const dataDir = await createDataDir(); - const store = createFileSnapshotStore({ scopeKey: "proj-1", dataDir }); - const capture = makeCapture("ses_1"); - await store.write("ses_1", capture); - const path = join( - dataDir, - "snapshots", - snapshotScopeDirName("proj-1"), - `${encodeURIComponent("ses_1")}.json`, - ); - const tampered = JSON.parse(JSON.stringify(capture)); - tampered.roles.default = "attacker/model"; - await writeFile(path, `${JSON.stringify(tampered)}\n`, "utf8"); - - await expect(store.read("ses_1")).rejects.toBeInstanceOf(SnapshotStoreError); - }); - - test("rejects structurally invalid or incomplete captures", () => { - expect(decodeFamilyCapture({ schemaVersion: 2 })).toBeUndefined(); - expect( - decodeFamilyCapture({ - schemaVersion: 1, - snapshotId: "s", - integrity: "x", - familyId: "f", - capturedAt: 1, - location: { directory: "/p", projectID: "p", canonical: "/p" }, - roles: {}, - roleModels: {}, - agents: [], - variants: [], - // modelSettings and vvoc missing - intent: { mode: "implicit", source: "config" }, - }), - ).toBeUndefined(); - }); -}); - -describe("FileBindingStore", () => { - function projection(apiKey: string) { - const config: VvocConfig = { - ...createDefaultVvocConfig(), - web: { - search: { provider: "exa", apiKey }, - fetch: { provider: "native", apiKey }, - }, - }; - return buildBehaviourProjection({ - roles: config.roles, - agentRoles: { build: "default" }, - vvoc: config, - }); - } - - function policyPath(dataDir: string, scopeKey: string, hash: string): string { - return join( - dataDir, - BINDING_STORE_DIR_NAME, - snapshotScopeDirName(scopeKey), - "policies", - `${hash}.json`, - ); - } - - test("round-trips content-addressed snapshots and family pointers", async () => { - const dataDir = await createDataDir(); - const store = new FileBindingStore({ scopeKey: "proj-1", dataDir }); - const value = projection("secret-key"); - const hash = await store.writeSnapshot(value); - expect(await store.readSnapshot(hash)).toEqual(value); - await store.writeBinding({ - familyId: "ses_1", - snapshotHash: hash, - boundAt: "2026-01-01T00:00:00.000Z", - }); - expect((await store.readBinding("ses_1"))?.snapshotHash).toBe(hash); - const counts = await store.countBindings(); - expect(counts.get(hash)).toBe(1); - await store.removeBinding("ses_1"); - expect(await store.readBinding("ses_1")).toBeUndefined(); - }); - - test("deduplicates identical snapshots and never writes credentials", async () => { - const dataDir = await createDataDir(); - const store = new FileBindingStore({ scopeKey: "proj-1", dataDir }); - const left = await store.writeSnapshot(projection("left-secret")); - const right = await store.writeSnapshot(projection("right-secret")); - expect(right).toBe(left); - expect(await store.listSnapshots()).toEqual([left]); - const text = await readFile(policyPath(dataDir, "proj-1", left), "utf8"); - expect(text).not.toContain("left-secret"); - expect(text).not.toContain("right-secret"); - expect(text).not.toContain("apiKey"); - }); - - test("fails soft on corrupt snapshots, mismatched hashes, and missing pointers", async () => { - const dataDir = await createDataDir(); - const store = new FileBindingStore({ scopeKey: "proj-1", dataDir }); - const hash = await store.writeSnapshot(projection("k")); - await writeFile(policyPath(dataDir, "proj-1", hash), "{ not json", "utf8"); - expect(await store.readSnapshot(hash)).toBeUndefined(); - expect(await store.readSnapshot("0".repeat(64))).toBeUndefined(); - expect(await store.readBinding("missing")).toBeUndefined(); - }); - - test("ignores the legacy snapshot directory", async () => { - const dataDir = await createDataDir(); - const legacyDir = join(dataDir, "snapshots", snapshotScopeDirName("proj-1")); - await mkdir(legacyDir, { recursive: true }); - await writeFile(join(legacyDir, "ses_legacy.json"), "{}", "utf8"); - const store = new FileBindingStore({ scopeKey: "proj-1", dataDir }); - expect(await store.listSnapshots()).toEqual([]); - expect(await store.listBindings()).toEqual([]); - }); -}); diff --git a/src/runtime/snapshot-store.ts b/src/runtime/snapshot-store.ts deleted file mode 100644 index ea23c125..00000000 --- a/src/runtime/snapshot-store.ts +++ /dev/null @@ -1,697 +0,0 @@ -// FILE: src/runtime/snapshot-store.ts -// VERSION: 2.0.0 -// START_MODULE_CONTRACT -// PURPOSE: Persist durable per-family policy captures and staged admission candidates under the vvoc data directory with atomic writes, project-scoped isolation, integrity verification, and fail-closed reads. -// SCOPE: File-backed SnapshotStore implementation, project-scoped snapshot root resolution, atomic write/rename, candidate staging files, family listing, content-integrity recomputation, deep-frozen immutable reads, and strict shape validation that rejects corrupt or foreign state. No admission logic, no native client, no host mutation. -// DEPENDS: [node:crypto, node:fs/promises, node:path, src/runtime/types.ts, src/lib/vvoc-paths.ts] -// LINKS: [M-NATIVE-RUNTIME, V-M-NATIVE-RUNTIME] -// ROLE: RUNTIME -// MAP_MODE: EXPORTS -// END_MODULE_CONTRACT -// -// START_MODULE_MAP -// SNAPSHOT_STORE_DIR_NAME - Directory name holding vvoc family snapshots inside the vvoc data dir. -// FileSnapshotStoreOptions - Project scope and optional data-dir override for the file store. -// createFileSnapshotStore - Build a durable file-backed SnapshotStore for one project scope. -// snapshotScopeDirName - Stable, collision-free directory name for one project scope. -// familyCaptureIntegrity - Recompute the behavior-relevant content digest of a capture. -// decodeFamilyCapture - Strictly validate and integrity-check a persisted family capture. -// decodeStagedCandidate - Strictly validate a persisted staged candidate. -// decodeStagedCandidates - Strictly validate a persisted staged-candidate array. -// BINDING_STORE_DIR_NAME - Directory name holding content-addressed policy snapshots and family pointers. -// FileBindingStoreOptions - Project scope and optional data-dir override for the binding store. -// FamilyBindingRecord - Durable pointer from a family to its content-addressed policy snapshot. -// FileBindingStore - Content-addressed snapshot and per-family pointer store with fail-soft reads. -// END_MODULE_MAP -// -// START_CHANGE_SUMMARY -// LAST_CHANGE: [C-SNAPSHOT-ANCHORING-REDESIGN T-003 - Added the content-addressed FileBindingStore with fail-soft reads and reference-counted GC primitives alongside the legacy capture store.] -// END_CHANGE_SUMMARY - -import { createHash, randomBytes } from "node:crypto"; -import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { parseVvocConfigText } from "../lib/vvoc-config.js"; -import { getGlobalVvocDataDir } from "../lib/vvoc-paths.js"; -import { behaviourContentHash, type BehaviourProjection } from "./snapshot-config.js"; -import { - SnapshotStoreError, - stagedCandidateKey, - type FamilyCapture, - type SnapshotStore, - type StagedCandidate, -} from "./types.js"; - -/** Directory name holding vvoc family snapshots inside the vvoc data dir. */ -export const SNAPSHOT_STORE_DIR_NAME = "snapshots"; - -/** Project scope and optional data-dir override for the file store. */ -export interface FileSnapshotStoreOptions { - /** - * Project scope key (project id). Family captures live under one project so a - * moved worktree still resolves its root while another project cannot read it. - */ - readonly scopeKey: string; - /** Override the vvoc data dir root; defaults to `$XDG_DATA_HOME/vvoc`. */ - readonly dataDir?: string | undefined; -} - -/** Stable, collision-free directory name for one project scope. */ -export function snapshotScopeDirName(scopeKey: string): string { - return createHash("sha256").update(scopeKey).digest("hex").slice(0, 32); -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -/** - * Behavior-relevant projection used for integrity. Generated variant ids and the - * snapshot id are excluded so the digest is stable and recomputable while still - * covering every policy, native overlay, and source variant that changes payload. - */ -function integrityProjection(capture: FamilyCapture): string { - return JSON.stringify({ - familyId: capture.familyId, - // `capturedAt` is an observation timestamp, never behavior: excluding it keeps - // the digest (and snapshot id) stable across unchanged restaging so a repeat - // admission is idempotent instead of creating a new revision. - location: capture.location, - roles: capture.roles, - roleModels: capture.roleModels, - agents: capture.agents, - variants: [...capture.variants] - .map((variant) => ({ - providerID: variant.providerID, - modelID: variant.modelID, - sourceVariant: variant.sourceVariant ?? null, - settings: variant.settings ?? null, - body: variant.body ?? null, - headers: variant.headers ?? null, - })) - .sort((left, right) => - `${left.providerID}/${left.modelID}/${left.sourceVariant}`.localeCompare( - `${right.providerID}/${right.modelID}/${right.sourceVariant}`, - ), - ), - modelSettings: capture.modelSettings, - vvoc: capture.vvoc, - rawIntent: capture.rawIntent ?? null, - rootSelection: capture.rootSelection ?? null, - modelOverride: capture.modelOverride ?? null, - intent: capture.intent, - }); -} - -/** Recompute the behavior-relevant content digest of a capture. */ -export function familyCaptureIntegrity(capture: FamilyCapture): string { - return createHash("sha256").update(integrityProjection(capture)).digest("hex"); -} - -function deepFreeze(value: T): T { - if (value === null || typeof value !== "object") return value; - if (Object.isFrozen(value)) return value; - for (const entry of Object.values(value as Record)) deepFreeze(entry); - return Object.freeze(value); -} - -function decodeSelection(value: unknown): FamilyCapture["roleModels"][string] | undefined { - if (!isRecord(value)) return undefined; - const { providerID, modelID, variant } = value; - if (typeof providerID !== "string" || typeof modelID !== "string") return undefined; - if (variant !== undefined && typeof variant !== "string") return undefined; - return { providerID, modelID, ...(variant === undefined ? {} : { variant }) }; -} - -function decodeSelections( - value: unknown, -): Record | undefined { - if (!isRecord(value)) return undefined; - const decoded: Record = {}; - for (const [key, entry] of Object.entries(value)) { - const selection = decodeSelection(entry); - if (selection === undefined) return undefined; - decoded[key] = selection; - } - return decoded; -} - -function decodeStringRecord(value: unknown): Record | undefined { - if (!isRecord(value)) return undefined; - const decoded: Record = {}; - for (const [key, entry] of Object.entries(value)) { - if (typeof entry !== "string") return undefined; - decoded[key] = entry; - } - return decoded; -} - -function decodeVariants(value: unknown): FamilyCapture["variants"] | undefined { - if (!Array.isArray(value)) return undefined; - const decoded: Array = []; - for (const entry of value) { - if (!isRecord(entry) || typeof entry.id !== "string") return undefined; - if (typeof entry.providerID !== "string" || typeof entry.modelID !== "string") return undefined; - if (entry.sourceVariant !== undefined && typeof entry.sourceVariant !== "string") { - return undefined; - } - const recorded: { - id: string; - providerID: string; - modelID: string; - sourceVariant?: string; - settings?: Readonly>; - body?: Readonly>; - headers?: Readonly>; - } = { id: entry.id, providerID: entry.providerID, modelID: entry.modelID }; - if (entry.sourceVariant !== undefined) recorded.sourceVariant = entry.sourceVariant; - if (entry.settings !== undefined) { - if (!isRecord(entry.settings)) return undefined; - recorded.settings = entry.settings; - } - if (entry.body !== undefined) { - if (!isRecord(entry.body)) return undefined; - recorded.body = entry.body; - } - if (entry.headers !== undefined) { - const headers = decodeStringRecord(entry.headers); - if (headers === undefined) return undefined; - recorded.headers = headers; - } - decoded.push(recorded); - } - return decoded; -} - -function decodeModelSettings(value: unknown): FamilyCapture["modelSettings"] | undefined { - if (!Array.isArray(value)) return undefined; - const decoded: Array = []; - for (const entry of value) { - if (!isRecord(entry)) return undefined; - const selection = decodeSelection(entry); - if (selection === undefined) return undefined; - const recorded: { - providerID: string; - modelID: string; - variant?: string; - settings?: Readonly>; - body?: Readonly>; - headers?: Readonly>; - } = selection; - if (entry.settings !== undefined) { - if (!isRecord(entry.settings)) return undefined; - recorded.settings = entry.settings; - } - if (entry.body !== undefined) { - if (!isRecord(entry.body)) return undefined; - recorded.body = entry.body; - } - if (entry.headers !== undefined) { - const headers = decodeStringRecord(entry.headers); - if (headers === undefined) return undefined; - recorded.headers = headers; - } - decoded.push(recorded); - } - return decoded; -} - -function decodeRawIntent(value: unknown): FamilyCapture["rawIntent"] | undefined { - if (value === undefined) return undefined; - if (!isRecord(value)) return undefined; - if (value.model !== undefined && typeof value.model !== "string") return undefined; - if (value.smallModel !== undefined && typeof value.smallModel !== "string") return undefined; - if (value.sourcePath !== undefined && typeof value.sourcePath !== "string") return undefined; - const agents = decodeStringRecord(value.agents); - const commands = decodeStringRecord(value.commands); - if (agents === undefined || commands === undefined) return undefined; - return { - ...(value.model === undefined ? {} : { model: value.model }), - ...(value.smallModel === undefined ? {} : { smallModel: value.smallModel }), - agents, - commands, - ...(value.sourcePath === undefined ? {} : { sourcePath: value.sourcePath }), - }; -} - -function decodeAgents(value: unknown): FamilyCapture["agents"] | undefined { - if (!Array.isArray(value)) return undefined; - const decoded: Array = []; - for (const entry of value) { - if (!isRecord(entry) || typeof entry.agentID !== "string") return undefined; - if (entry.role !== undefined && typeof entry.role !== "string") return undefined; - const selection = entry.selection === undefined ? undefined : decodeSelection(entry.selection); - if (entry.selection !== undefined && selection === undefined) return undefined; - decoded.push({ - agentID: entry.agentID, - ...(entry.role === undefined ? {} : { role: entry.role }), - ...(selection === undefined ? {} : { selection }), - }); - } - return decoded; -} - -function decodeIntent(value: unknown): FamilyCapture["intent"] | undefined { - if (!isRecord(value)) return undefined; - const { mode, source, roleRef, literal } = value; - if (mode !== "explicit" && mode !== "implicit" && mode !== "staged") return undefined; - if (source !== "config" && source !== "switch" && source !== "watcher" && source !== "preset") { - return undefined; - } - if (roleRef !== undefined && typeof roleRef !== "string") return undefined; - const decodedLiteral = literal === undefined ? undefined : decodeSelection(literal); - if (literal !== undefined && decodedLiteral === undefined) return undefined; - return { - mode, - source, - ...(roleRef === undefined ? {} : { roleRef }), - ...(decodedLiteral === undefined ? {} : { literal: decodedLiteral }), - }; -} - -function decodeIdentity(value: unknown): FamilyCapture["location"] | undefined { - if (!isRecord(value)) return undefined; - const { directory, workspaceID, projectID, canonical } = value; - if ( - typeof directory !== "string" || - typeof projectID !== "string" || - typeof canonical !== "string" || - (workspaceID !== undefined && typeof workspaceID !== "string") - ) { - return undefined; - } - return { directory, ...(workspaceID === undefined ? {} : { workspaceID }), projectID, canonical }; -} - -/** Strictly validate and integrity-check a persisted family capture. */ -export function decodeFamilyCapture(value: unknown): FamilyCapture | undefined { - if (!isRecord(value)) return undefined; - if (value.schemaVersion !== 1) return undefined; - if (typeof value.snapshotId !== "string" || typeof value.integrity !== "string") return undefined; - if (typeof value.familyId !== "string") return undefined; - if (typeof value.capturedAt !== "number" || !Number.isFinite(value.capturedAt)) return undefined; - const location = decodeIdentity(value.location); - if (location === undefined) return undefined; - const roles = decodeStringRecord(value.roles); - if (roles === undefined) return undefined; - const roleModels = decodeSelections(value.roleModels); - if (roleModels === undefined) return undefined; - const agents = decodeAgents(value.agents); - if (agents === undefined) return undefined; - const variants = decodeVariants(value.variants); - if (variants === undefined) return undefined; - const modelSettings = decodeModelSettings(value.modelSettings); - if (modelSettings === undefined) return undefined; - if (!isRecord(value.vvoc)) return undefined; - let vvoc; - try { - // Re-validate the captured document with the canonical strict parser; never trust raw bytes. - vvoc = parseVvocConfigText(JSON.stringify(value.vvoc), `snapshot:${value.familyId}`); - } catch { - return undefined; - } - const rawIntent = decodeRawIntent(value.rawIntent); - if (value.rawIntent !== undefined && rawIntent === undefined) return undefined; - const intent = decodeIntent(value.intent); - if (intent === undefined) return undefined; - const modelOverride = - value.modelOverride === undefined ? undefined : decodeSelection(value.modelOverride); - if (value.modelOverride !== undefined && modelOverride === undefined) return undefined; - const rootSelection = - value.rootSelection === undefined ? undefined : decodeSelection(value.rootSelection); - if (value.rootSelection !== undefined && rootSelection === undefined) return undefined; - - const decoded: FamilyCapture = deepFreeze({ - schemaVersion: 1, - snapshotId: value.snapshotId, - integrity: value.integrity, - familyId: value.familyId, - capturedAt: value.capturedAt, - location, - roles, - roleModels, - agents, - variants, - modelSettings, - vvoc, - ...(rawIntent === undefined ? {} : { rawIntent }), - ...(rootSelection === undefined ? {} : { rootSelection }), - ...(modelOverride === undefined ? {} : { modelOverride }), - intent, - }); - - // A mismatch means the persisted state is stale, tampered, or from an incompatible version. - if (familyCaptureIntegrity(decoded) !== decoded.integrity) return undefined; - return decoded; -} - -/** Strictly validate a persisted staged candidate. */ -export function decodeStagedCandidate(value: unknown): StagedCandidate | undefined { - if (!isRecord(value)) return undefined; - if (typeof value.familyId !== "string" || typeof value.sessionID !== "string") return undefined; - if (typeof value.stagedAt !== "number" || !Number.isFinite(value.stagedAt)) return undefined; - if (typeof value.revision !== "string") return undefined; - if (value.inboxID !== undefined && typeof value.inboxID !== "string") return undefined; - if (value.operationID !== undefined && typeof value.operationID !== "string") return undefined; - if (value.workload !== undefined && typeof value.workload !== "string") return undefined; - const selection = decodeSelection(value.selection); - if (selection === undefined) return undefined; - const before = value.before === undefined ? undefined : decodeSelection(value.before); - if (value.before !== undefined && before === undefined) return undefined; - const capture = decodeFamilyCapture(value.capture); - if (capture === undefined) return undefined; - return deepFreeze({ - familyId: value.familyId, - sessionID: value.sessionID, - ...(value.inboxID === undefined ? {} : { inboxID: value.inboxID }), - ...(value.operationID === undefined ? {} : { operationID: value.operationID }), - ...(value.workload === undefined ? {} : { workload: value.workload }), - stagedAt: value.stagedAt, - revision: value.revision, - selection, - ...(before === undefined ? {} : { before }), - capture, - }); -} - -/** Strictly validate a persisted staged-candidate array; any invalid entry fails the whole read. */ -export function decodeStagedCandidates(value: unknown): StagedCandidate[] | undefined { - if (!Array.isArray(value)) return undefined; - const decoded: StagedCandidate[] = []; - const seen = new Set(); - for (const entry of value) { - const candidate = decodeStagedCandidate(entry); - if (candidate === undefined) return undefined; - const key = stagedCandidateKey(candidate); - if (seen.has(key)) return undefined; - seen.add(key); - decoded.push(candidate); - } - return decoded; -} - -// START_BLOCK_FILE_STORE -/** Build a durable file-backed SnapshotStore for one project scope. */ -export function createFileSnapshotStore(options: FileSnapshotStoreOptions): SnapshotStore { - const baseDir = join( - options.dataDir ?? getGlobalVvocDataDir(), - SNAPSHOT_STORE_DIR_NAME, - snapshotScopeDirName(options.scopeKey), - ); - - const capturePath = (familyId: string) => join(baseDir, `${encodeURIComponent(familyId)}.json`); - const candidatesPath = (familyId: string) => - join(baseDir, `${encodeURIComponent(familyId)}.candidates.json`); - const markerPath = (familyId: string) => - join(baseDir, `${encodeURIComponent(familyId)}.bound.json`); - - async function readJson(path: string): Promise { - let text: string; - try { - text = await readFile(path, "utf8"); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; - throw new SnapshotStoreError(`Snapshot state could not be read at ${path}.`, { - cause: error, - }); - } - try { - return JSON.parse(text); - } catch (error) { - throw new SnapshotStoreError(`Snapshot state at ${path} is not valid JSON.`, { - cause: error, - }); - } - } - - async function writeAtomic(path: string, value: unknown): Promise { - await mkdir(dirname(path), { recursive: true, mode: 0o700 }); - const temp = `${path}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`; - try { - await writeFile(temp, `${JSON.stringify(value)}\n`, { encoding: "utf8", mode: 0o600 }); - await rename(temp, path); - } catch (error) { - await rm(temp, { force: true }); - throw new SnapshotStoreError(`Snapshot state could not be written at ${path}.`, { - cause: error, - }); - } - } - - async function readCandidatesFor(familyId: string): Promise { - const raw = await readJson(candidatesPath(familyId)); - if (raw === undefined) return []; - const candidates = decodeStagedCandidates(raw); - if (candidates === undefined) { - throw new SnapshotStoreError( - `Staged admission candidates for family ${familyId} are invalid.`, - ); - } - return candidates; - } - - return { - async read(familyId) { - const raw = await readJson(capturePath(familyId)); - if (raw === undefined) return undefined; - const capture = decodeFamilyCapture(raw); - if (capture === undefined) { - throw new SnapshotStoreError( - `Snapshot state for family ${familyId} is invalid; refusing to adopt current configuration.`, - ); - } - return capture; - }, - async write(familyId, capture) { - await writeAtomic(capturePath(familyId), capture); - }, - async remove(familyId) { - await rm(capturePath(familyId), { force: true }); - }, - readCandidates: readCandidatesFor, - async writeCandidate(candidate) { - const existing = await readCandidatesFor(candidate.familyId); - const key = stagedCandidateKey(candidate); - const merged = existing.filter((entry) => stagedCandidateKey(entry) !== key); - merged.push(candidate); - await writeAtomic(candidatesPath(candidate.familyId), merged); - }, - async removeCandidate(familyId, candidateKey) { - const existing = await readCandidatesFor(familyId); - const remaining = existing.filter((entry) => stagedCandidateKey(entry) !== candidateKey); - if (remaining.length === existing.length) return; - if (remaining.length === 0) { - await rm(candidatesPath(familyId), { force: true }); - return; - } - await writeAtomic(candidatesPath(familyId), remaining); - }, - async removeCandidates(familyId) { - await rm(candidatesPath(familyId), { force: true }); - }, - async readMarker(familyId) { - const raw = await readJson(markerPath(familyId)); - return raw !== undefined; - }, - async writeMarker(familyId) { - await writeAtomic(markerPath(familyId), { bound: true }); - }, - async removeMarker(familyId) { - await rm(markerPath(familyId), { force: true }); - }, - async list() { - let entries: string[]; - try { - entries = await readdir(baseDir); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; - throw new SnapshotStoreError(`Snapshot directory could not be listed at ${baseDir}.`, { - cause: error, - }); - } - const familyIds: string[] = []; - for (const entry of entries) { - if ( - !entry.endsWith(".json") || - entry.endsWith(".candidates.json") || - entry.endsWith(".bound.json") - ) - continue; - familyIds.push(decodeURIComponent(entry.slice(0, -".json".length))); - } - return familyIds; - }, - }; -} -// END_BLOCK_FILE_STORE - -// START_BLOCK_BINDING_STORE -/** Directory name holding content-addressed policy snapshots and family pointers. */ -export const BINDING_STORE_DIR_NAME = "bindings"; - -/** Project scope and optional data-dir override for the content-addressed binding store. */ -export interface FileBindingStoreOptions { - /** Project scope key; families in one project cannot read another project's snapshots. */ - readonly scopeKey: string; - /** Override the vvoc data dir root; defaults to `$XDG_DATA_HOME/vvoc`. */ - readonly dataDir?: string | undefined; -} - -/** Durable pointer from one family to its content-addressed policy snapshot. */ -export interface FamilyBindingRecord { - readonly familyId: string; - readonly snapshotHash: string; - readonly boundAt: string; -} - -function decodeFamilyBinding(text: string): FamilyBindingRecord | undefined { - try { - const value = JSON.parse(text) as unknown; - if (!isRecord(value)) return undefined; - if ( - typeof value.familyId !== "string" || - typeof value.snapshotHash !== "string" || - typeof value.boundAt !== "string" - ) { - return undefined; - } - return { familyId: value.familyId, snapshotHash: value.snapshotHash, boundAt: value.boundAt }; - } catch { - return undefined; - } -} - -function decodeBehaviourProjection(text: string): BehaviourProjection | undefined { - try { - const value = JSON.parse(text) as unknown; - if (!isRecord(value)) return undefined; - if ( - !isRecord(value.roleModels) || - !Array.isArray(value.agentBindings) || - !isRecord(value.policy) - ) { - return undefined; - } - return value as unknown as BehaviourProjection; - } catch { - return undefined; - } -} - -async function writeJsonAtomic(path: string, value: unknown): Promise { - await mkdir(dirname(path), { recursive: true }); - const temporary = `${path}.${randomBytes(6).toString("hex")}.tmp`; - await writeFile(temporary, JSON.stringify(value), { mode: 0o600 }); - await rename(temporary, path); -} - -async function readTextSafe(path: string): Promise { - try { - return await readFile(path, "utf8"); - } catch { - return undefined; - } -} - -async function readNamesSafe(path: string): Promise { - try { - return await readdir(path); - } catch { - return []; - } -} - -/** - * Content-addressed store for credential-free behaviour-policy snapshots plus a - * tiny per-family pointer. Reads never throw: a missing, unparseable, or - * hash-mismatched artifact returns undefined so callers can fail soft per family. - */ -export class FileBindingStore { - readonly #root: string; - - constructor(options: FileBindingStoreOptions) { - const dataDir = options.dataDir ?? getGlobalVvocDataDir(); - this.#root = join(dataDir, BINDING_STORE_DIR_NAME, snapshotScopeDirName(options.scopeKey)); - } - - #policiesDir(): string { - return join(this.#root, "policies"); - } - - #familiesDir(): string { - return join(this.#root, "families"); - } - - #policyPath(hash: string): string { - return join(this.#policiesDir(), `${hash}.json`); - } - - #familyPath(familyId: string): string { - return join(this.#familiesDir(), `${encodeURIComponent(familyId)}.json`); - } - - /** Persist a projection under its content hash and return that hash; identical content reuses the file. */ - async writeSnapshot(projection: BehaviourProjection): Promise { - const contentHash = behaviourContentHash(projection); - await writeJsonAtomic(this.#policyPath(contentHash), projection); - return contentHash; - } - - /** Load a projection by hash; undefined when missing, corrupt, or hash-mismatched. */ - async readSnapshot(hash: string): Promise { - const text = await readTextSafe(this.#policyPath(hash)); - if (text === undefined) return undefined; - const decoded = decodeBehaviourProjection(text); - if (decoded === undefined) return undefined; - if (behaviourContentHash(decoded) !== hash) return undefined; - return decoded; - } - - async writeBinding(record: FamilyBindingRecord): Promise { - await writeJsonAtomic(this.#familyPath(record.familyId), record); - } - - /** Load a family pointer; undefined when missing or corrupt. */ - async readBinding(familyId: string): Promise { - const text = await readTextSafe(this.#familyPath(familyId)); - if (text === undefined) return undefined; - return decodeFamilyBinding(text); - } - - async removeBinding(familyId: string): Promise { - await rm(this.#familyPath(familyId), { force: true }); - } - - async listBindings(): Promise { - const records: FamilyBindingRecord[] = []; - for (const name of await readNamesSafe(this.#familiesDir())) { - if (!name.endsWith(".json")) continue; - const text = await readTextSafe(join(this.#familiesDir(), name)); - if (text === undefined) continue; - const record = decodeFamilyBinding(text); - if (record !== undefined) records.push(record); - } - return records; - } - - async listSnapshots(): Promise { - const names = await readNamesSafe(this.#policiesDir()); - return names - .filter((name) => name.endsWith(".json")) - .map((name) => name.slice(0, -".json".length)); - } - - async removeSnapshot(hash: string): Promise { - await rm(this.#policyPath(hash), { force: true }); - } - - /** Count how many live families reference each snapshot hash, for reference-counted garbage collection. */ - async countBindings(): Promise> { - const counts = new Map(); - for (const record of await this.listBindings()) { - counts.set(record.snapshotHash, (counts.get(record.snapshotHash) ?? 0) + 1); - } - return counts; - } -} -// END_BLOCK_BINDING_STORE diff --git a/src/runtime/snapshots.test.ts b/src/runtime/snapshots.test.ts deleted file mode 100644 index 24d88d7d..00000000 --- a/src/runtime/snapshots.test.ts +++ /dev/null @@ -1,376 +0,0 @@ -// FILE: src/runtime/snapshots.test.ts -// VERSION: 2.0.0 -// START_MODULE_CONTRACT -// PURPOSE: Verify durable credential-free family binding, rehydration, fail-soft repair, concurrent first-work serialization, rebind, deletion garbage collection, and live credential materialization. -// SCOPE: In-memory binding-store and host-boundary doubles; no real filesystem or native host. -// DEPENDS: [bun:test, src/runtime/snapshot-config.ts, src/runtime/snapshots.ts, src/runtime/types.ts] -// LINKS: [M-NATIVE-RUNTIME, V-M-NATIVE-RUNTIME] -// ROLE: TEST -// MAP_MODE: LOCALS -// END_MODULE_CONTRACT -// -// START_MODULE_MAP -// LOCATION - Stable project identity used by binding requests. -// config - Build a minimal effective configuration for a selected default model. -// MemoryBindingStore - In-memory content-addressed snapshot and family-pointer double. -// FakeHost - Mutable config/session/model boundary double. -// request - Build a first-work binding request for a session. -// END_MODULE_MAP -// -// START_CHANGE_SUMMARY -// LAST_CHANGE: [C-SNAPSHOT-ANCHORING-REDESIGN T-004/T-005 - Replaced candidate-admission coverage with durable family-binding lifecycle and credential-safe rehydration coverage.] -// END_CHANGE_SUMMARY - -import { describe, expect, test } from "bun:test"; -import { createDefaultVvocConfig } from "../lib/vvoc-config.js"; -import { behaviourContentHash, type BehaviourProjection } from "./snapshot-config.js"; -import { - createSnapshotService, - resolveSessionFamily, - type SnapshotServiceDeps, -} from "./snapshots.js"; -import type { FamilyBindingRecord } from "./snapshot-store.js"; -import { - SnapshotAdmissionError, - type EffectiveRuntimeConfig, - type ModelSelection, - type NativeSessionView, - type RuntimeIdentity, -} from "./types.js"; - -const LOCATION: RuntimeIdentity = { - directory: "/project", - projectID: "proj", - canonical: "/project", -}; - -function config(modelID: string): EffectiveRuntimeConfig { - return { - roles: { default: `provider/${modelID}` }, - agentRoles: { build: "default" }, - vvoc: createDefaultVvocConfig(), - }; -} - -class MemoryBindingStore { - readonly snapshots = new Map(); - readonly bindings = new Map(); - writes = 0; - - async writeSnapshot(projection: BehaviourProjection): Promise { - const hash = behaviourContentHash(projection); - this.snapshots.set(hash, structuredClone(projection)); - return hash; - } - - async readSnapshot(hash: string): Promise { - const snapshot = this.snapshots.get(hash); - if (snapshot === undefined || behaviourContentHash(snapshot) !== hash) return undefined; - return structuredClone(snapshot); - } - - async writeBinding(binding: FamilyBindingRecord): Promise { - this.writes += 1; - this.bindings.set(binding.familyId, { ...binding }); - } - - async readBinding(familyId: string): Promise { - const binding = this.bindings.get(familyId); - return binding === undefined ? undefined : { ...binding }; - } - - async removeBinding(familyId: string): Promise { - this.bindings.delete(familyId); - } - - async listBindings(): Promise { - return [...this.bindings.values()].map((binding) => ({ ...binding })); - } - - async listSnapshots(): Promise { - return [...this.snapshots.keys()]; - } - - async removeSnapshot(hash: string): Promise { - this.snapshots.delete(hash); - } - - async countBindings(): Promise> { - const counts = new Map(); - for (const binding of this.bindings.values()) { - counts.set(binding.snapshotHash, (counts.get(binding.snapshotHash) ?? 0) + 1); - } - return counts; - } -} - -class FakeHost { - readonly sessions = new Map(); - readonly models = new Map(); - readonly switches: Array<{ readonly sessionID: string; readonly model: ModelSelection }> = []; - readonly diagnostics: string[] = []; - config = config("one"); - pendingRebinds: Array<{ - id: string; - requestedAt: number; - directory: string; - sessionId?: string; - }> = []; - - constructor(readonly store: MemoryBindingStore) {} - - get deps(): SnapshotServiceDeps { - return { - store: this.store, - location: LOCATION, - loadConfig: async () => this.config, - readSession: async (sessionID) => this.sessions.get(sessionID), - readSessionModel: async (sessionID) => this.models.get(sessionID), - switchModel: async (input) => { - this.switches.push(input); - this.models.set(input.sessionID, input.model); - }, - auxiliarySession: { - create: async () => ({ sessionID: "aux" }), - switchModel: async () => undefined, - generate: async () => ({ text: "ok" }), - }, - diagnostic: (message) => this.diagnostics.push(message), - rebindRequests: async () => this.pendingRebinds, - now: () => 1_700_000_000_000, - }; - } - - add(id: string, parentID?: string): void { - this.sessions.set(id, { id, ...(parentID === undefined ? {} : { parentID }) }); - } -} - -function request(sessionID: string) { - return { sessionID, directory: LOCATION.directory, location: LOCATION }; -} - -describe("family bindings", () => { - test("binds first work and rehydrates the original revision after a restart and config edit", async () => { - const store = new MemoryBindingStore(); - const host = new FakeHost(store); - host.add("root"); - const first = createSnapshotService(host.deps); - - expect((await first.admitOwned(request("root"))).status).toBe("bound"); - const originalHash = store.bindings.get("root")?.snapshotHash; - host.config = config("two"); - - const restarted = createSnapshotService(host.deps); - const capture = await restarted.configFor("root"); - expect(capture?.roleModels.default.modelID).toBe("one"); - expect(store.bindings.get("root")?.snapshotHash).toBe(originalHash); - }); - - test("leaves an existing family on its bound revision while a new family binds current config", async () => { - const store = new MemoryBindingStore(); - const host = new FakeHost(store); - host.add("existing"); - host.add("new"); - const service = createSnapshotService(host.deps); - - await service.admitOwned(request("existing")); - const existingHash = store.bindings.get("existing")?.snapshotHash; - host.config = config("two"); - - expect((await service.admitOwned(request("existing"))).status).toBe("reused"); - expect((await service.admitOwned(request("new"))).status).toBe("bound"); - expect((await service.configFor("existing"))?.roleModels.default.modelID).toBe("one"); - expect((await service.configFor("new"))?.roleModels.default.modelID).toBe("two"); - expect(store.bindings.get("existing")?.snapshotHash).toBe(existingHash); - expect(store.bindings.get("new")?.snapshotHash).not.toBe(existingHash); - }); - - test("a pending force-rebind marker moves an existing family to current config on its next workload", async () => { - const store = new MemoryBindingStore(); - const host = new FakeHost(store); - host.add("root"); - const service = createSnapshotService(host.deps); - - await service.admitOwned(request("root")); - const originalHash = store.bindings.get("root")?.snapshotHash; - expect((await service.configFor("root"))?.roleModels.default.modelID).toBe("one"); - - host.config = config("two"); - host.pendingRebinds.push({ - id: "req-1", - requestedAt: 1_700_000_000_001, - directory: LOCATION.directory, - }); - - const outcome = await service.admitOwned(request("root")); - expect(outcome.status).toBe("bound"); - expect(store.bindings.get("root")?.snapshotHash).not.toBe(originalHash); - expect((await service.configFor("root"))?.roleModels.default.modelID).toBe("two"); - expect(host.switches.at(-1)?.model.modelID).toBe("two"); - }); - - test("a pending marker for another project does not rebind the family", async () => { - const store = new MemoryBindingStore(); - const host = new FakeHost(store); - host.add("root"); - const service = createSnapshotService(host.deps); - await service.admitOwned(request("root")); - const originalHash = store.bindings.get("root")?.snapshotHash; - - host.config = config("two"); - host.pendingRebinds.push({ id: "req-2", requestedAt: 1_700_000_000_001, directory: "/other" }); - - expect((await service.admitOwned(request("root"))).status).toBe("reused"); - expect(store.bindings.get("root")?.snapshotHash).toBe(originalHash); - expect((await service.configFor("root"))?.roleModels.default.modelID).toBe("one"); - }); - - test("serializes concurrent first workloads for one family", async () => { - const store = new MemoryBindingStore(); - const host = new FakeHost(store); - host.add("root"); - host.add("child", "root"); - const service = createSnapshotService(host.deps); - - const [left, right] = await Promise.all([ - service.admitOwned(request("root")), - service.admitOwned(request("child")), - ]); - - expect([left.status, right.status].sort()).toEqual(["bound", "reused"]); - expect(store.writes).toBe(1); - }); - - test("repairs a missing snapshot on the affected family's next workload", async () => { - const store = new MemoryBindingStore(); - const host = new FakeHost(store); - host.add("root"); - host.add("other"); - const service = createSnapshotService(host.deps); - await service.admitOwned(request("other")); - store.bindings.set("root", { - familyId: "root", - snapshotHash: "missing", - boundAt: new Date(0).toISOString(), - }); - - const outcome = await service.admitOwned(request("root")); - expect(outcome.status).toBe("bound"); - expect(store.bindings.get("other")).toBeDefined(); - expect(host.diagnostics.some((message) => message.includes("snapshot"))).toBe(true); - }); - - test("repairs a corrupt pointer and a missing pointer without affecting other families", async () => { - const store = new MemoryBindingStore(); - const host = new FakeHost(store); - host.add("corrupt"); - host.add("missing"); - host.add("other"); - const service = createSnapshotService(host.deps); - await service.admitOwned(request("corrupt")); - await service.admitOwned(request("missing")); - await service.admitOwned(request("other")); - const otherHash = store.bindings.get("other")?.snapshotHash; - host.config = config("two"); - - store.bindings.set("corrupt", { - familyId: "wrong-family", - snapshotHash: "not-used", - boundAt: new Date(0).toISOString(), - }); - store.bindings.delete("missing"); - - expect((await service.admitOwned(request("corrupt"))).status).toBe("bound"); - expect((await service.admitOwned(request("missing"))).status).toBe("bound"); - expect((await service.configFor("corrupt"))?.roleModels.default.modelID).toBe("two"); - expect((await service.configFor("missing"))?.roleModels.default.modelID).toBe("two"); - expect(store.bindings.get("other")?.snapshotHash).toBe(otherHash); - expect(host.diagnostics.some((message) => message.includes("pointer"))).toBe(true); - }); - - test("materializes live credentials without storing them in the projection", async () => { - const store = new MemoryBindingStore(); - const host = new FakeHost(store); - host.add("root"); - host.config = config("one"); - host.config.vvoc.web = { - search: { apiKey: "test-only-search-key" }, - fetch: { apiKey: "test-only-fetch-key" }, - }; - host.config.vvoc.secretsRedaction.secret = "test-only-redaction-secret"; - const service = createSnapshotService(host.deps); - - await service.admitOwned(request("root")); - const stored = await store.readSnapshot(store.bindings.get("root")!.snapshotHash); - const capture = await service.configFor("root"); - - expect(JSON.stringify(stored)).not.toContain("test-only"); - expect(capture?.vvoc.web?.search?.apiKey).toBe("test-only-search-key"); - expect(capture?.vvoc.web?.fetch?.apiKey).toBe("test-only-fetch-key"); - expect(capture?.vvoc.secretsRedaction.secret).toBe("test-only-redaction-secret"); - - host.config.vvoc.web = { search: { apiKey: "rotated-search-key" } }; - host.config.vvoc.secretsRedaction.secret = "rotated-redaction-secret"; - const restarted = createSnapshotService(host.deps); - const reattached = await restarted.configFor("root"); - expect(reattached?.vvoc.web?.search?.apiKey).toBe("rotated-search-key"); - expect(reattached?.vvoc.secretsRedaction.secret).toBe("rotated-redaction-secret"); - expect( - JSON.stringify(await store.readSnapshot(store.bindings.get("root")!.snapshotHash)), - ).not.toContain("rotated"); - }); - - test("force rebind replaces matching pointers and deletion keeps then collects shared snapshots", async () => { - const store = new MemoryBindingStore(); - const host = new FakeHost(store); - host.add("root"); - host.add("other"); - host.add("child", "root"); - const service = createSnapshotService(host.deps); - await service.admitOwned(request("root")); - await service.admitOwned(request("other")); - const originalHash = store.bindings.get("root")!.snapshotHash; - - host.config = config("two"); - expect( - await service.rebind({ directory: LOCATION.directory, location: LOCATION, familyId: "root" }), - ).toEqual(["root"]); - expect( - await service.rebind({ - directory: LOCATION.directory, - location: { ...LOCATION, projectID: "other-project" }, - }), - ).toEqual([]); - expect(store.bindings.get("root")?.snapshotHash).not.toBe(originalHash); - expect(store.bindings.get("other")?.snapshotHash).toBe(originalHash); - - // A deletion event for a known fork resolves to the root family pointer. - await service.familyOf("child"); - await service.removeFamily("child"); - expect(store.bindings.has("root")).toBe(false); - expect(store.snapshots.has(originalHash)).toBe(true); - await service.removeFamily("other"); - expect(store.snapshots.has(originalHash)).toBe(false); - expect(store.snapshots.size).toBe(0); - }); -}); - -describe("resolveSessionFamily", () => { - test("uses host lineage and rejects cycles", async () => { - const sessions = new Map([ - ["child", { id: "child", parentID: "root" }], - ["root", { id: "root" }], - ]); - expect( - await resolveSessionFamily({ - sessionID: "child", - readSession: async (id) => sessions.get(id), - }), - ).toBe("root"); - sessions.set("root", { id: "root", parentID: "child" }); - await expect( - resolveSessionFamily({ sessionID: "child", readSession: async (id) => sessions.get(id) }), - ).rejects.toBeInstanceOf(SnapshotAdmissionError); - }); -}); diff --git a/src/runtime/snapshots.ts b/src/runtime/snapshots.ts deleted file mode 100644 index 110a5078..00000000 --- a/src/runtime/snapshots.ts +++ /dev/null @@ -1,624 +0,0 @@ -// FILE: src/runtime/snapshots.ts -// VERSION: 2.0.0 -// START_MODULE_CONTRACT -// PURPOSE: Bind each native session family to one credential-free, content-addressed behaviour policy at its first workload and materialize that stable policy with live credentials and native overlays for plugin consumers. -// SCOPE: Host-verified family resolution, per-family serialized bind/rehydrate/rebind/delete lifecycle, fail-soft binding-store recovery, reference-counted garbage collection, live credential attachment, variant derivation, and the SnapshotService consumed by native plugins. No candidate staging, accepted-input ordering, cross-context publication, host discovery, client authentication, or stateless generation. -// DEPENDS: [src/runtime/auxiliary.ts, src/runtime/coordination.ts, src/runtime/model-registry.ts, src/runtime/snapshot-config.ts, src/runtime/snapshot-store.ts, src/runtime/types.ts] -// LINKS: [M-NATIVE-RUNTIME, V-M-NATIVE-RUNTIME] -// ROLE: RUNTIME -// MAP_MODE: EXPORTS -// END_MODULE_CONTRACT -// -// START_MODULE_MAP -// MAX_LINEAGE_HOPS - Bound on host-verified parent/fork lineage traversal. -// SnapshotServiceDeps - Injectable host and durable-binding boundaries. -// resolveSessionFamily - Resolve a session's family root through host-verified lineage. -// createSnapshotService - Build the credential-safe family-binding service for one native context. -// END_MODULE_MAP -// -// START_CHANGE_SUMMARY -// LAST_CHANGE: [C-SNAPSHOT-ANCHORING-REDESIGN T-004/T-005/T-006 - Replaced staged candidate capture with fail-soft, per-family content-addressed bindings that rehydrate across restart, consume pending CLI force-rebind markers, clean up on deletion, and attach credentials only in memory.] -// END_CHANGE_SUMMARY - -import { createAuxiliaryService } from "./auxiliary.js"; -import type { HostCoordination } from "./coordination.js"; -import { managedVariantFor, primarySelection, qualifySelection } from "./model-registry.js"; -import { - behaviourContentHash, - buildBehaviourProjection, - type BehaviourProjection, -} from "./snapshot-config.js"; -import { FileBindingStore, type FamilyBindingRecord } from "./snapshot-store.js"; -import { - listRebindRequests, - rebindRequestMatches, - type RebindRequest, -} from "../lib/rebind-request.js"; -import { - SnapshotAdmissionError, - type AdmissionOutcome, - type CapturedModelSettings, - type EffectiveRuntimeConfig, - type FamilyCapture, - type ModelSelection, - type ModelVariantCapture, - type NativeSessionView, - type RuntimeIdentity, - type SnapshotAcceptRequest, - type SnapshotAdmissionRequest, - type SnapshotService, - type VariantRegistration, -} from "./types.js"; - -/** Bound on host-verified parent/fork lineage traversal. */ -export const MAX_LINEAGE_HOPS = 64; - -type BindingStore = Pick< - FileBindingStore, - | "writeSnapshot" - | "readSnapshot" - | "writeBinding" - | "readBinding" - | "removeBinding" - | "listBindings" - | "listSnapshots" - | "removeSnapshot" - | "countBindings" ->; - -/** Injectable host and durable-binding boundaries for the snapshot service. */ -export interface SnapshotServiceDeps { - readonly store: BindingStore; - readonly location: RuntimeIdentity; - loadConfig(directory: string): Promise; - /** Host-verified session view; never model-supplied identity. */ - readSession(sessionID: string): Promise; - readSessionModel(sessionID: string): Promise; - switchModel(input: { readonly sessionID: string; readonly model: ModelSelection }): Promise; - readonly auxiliarySession: import("./types.js").AuxiliarySessionApi; - now?(): number; - /** Optional bounded, credential-safe diagnostic sink. */ - diagnostic?(message: string): void; - /** Optional shared per-family lock for distinct plugin contexts in one host. */ - readonly coordination?: HostCoordination | undefined; - /** Optional pending rebind-request reader; defaults to the CLI marker directory. */ - readonly rebindRequests?: (() => Promise) | undefined; -} - -function variantIdFor( - snapshotId: string, - modelID: string, - sourceVariant: string | undefined, -): string { - return sourceVariant === undefined - ? `${snapshotId}.${modelID}` - : `${snapshotId}.${modelID}.${sourceVariant}`; -} - -function variantOverlayFor( - selection: ModelSelection, - modelSettings: ReadonlyArray, -): Pick { - const match = modelSettings.find( - (settings) => - settings.providerID === selection.providerID && - settings.modelID === selection.modelID && - (selection.variant === undefined || settings.variant === selection.variant), - ); - return { - ...(match?.settings === undefined ? {} : { settings: match.settings }), - ...(match?.body === undefined ? {} : { body: match.body }), - ...(match?.headers === undefined ? {} : { headers: match.headers }), - }; -} - -function buildVariants( - snapshotId: string, - selections: Readonly>, - modelSettings: ReadonlyArray, -): ModelVariantCapture[] { - const byKey = new Map(); - for (const selection of Object.values(selections)) { - const overlay = variantOverlayFor(selection, modelSettings); - const managed = managedVariantFor(selection, snapshotId); - const hasOverlay = - overlay.settings !== undefined || overlay.body !== undefined || overlay.headers !== undefined; - if (!hasOverlay && managed === undefined) continue; - const sourceVariant = managed?.sourceVariant ?? selection.variant; - const body = { ...overlay.body, ...managed?.body }; - const key = `${selection.providerID}\u0000${selection.modelID}\u0000${sourceVariant ?? ""}`; - byKey.set(key, { - id: variantIdFor(snapshotId, selection.modelID, sourceVariant), - providerID: selection.providerID, - modelID: selection.modelID, - ...(sourceVariant === undefined ? {} : { sourceVariant }), - ...(overlay.settings === undefined ? {} : { settings: overlay.settings }), - ...(Object.keys(body).length === 0 ? {} : { body }), - ...(overlay.headers === undefined ? {} : { headers: overlay.headers }), - }); - } - return [...byKey.values()]; -} - -/** Attach only live credential fields; the stored projection never carries them. */ -function policyWithLiveCredentials( - projection: BehaviourProjection, - live: EffectiveRuntimeConfig | undefined, -): FamilyCapture["vvoc"] { - const policy = structuredClone(projection.policy); - if (live === undefined) return policy; - policy.secretsRedaction = { - ...policy.secretsRedaction, - secret: live.vvoc.secretsRedaction.secret, - }; - const searchKey = live.vvoc.web?.search?.apiKey; - const fetchKey = live.vvoc.web?.fetch?.apiKey; - if (searchKey !== undefined || fetchKey !== undefined) { - policy.web = { - ...policy.web, - ...(searchKey === undefined ? {} : { search: { ...policy.web?.search, apiKey: searchKey } }), - ...(fetchKey === undefined ? {} : { fetch: { ...policy.web?.fetch, apiKey: fetchKey } }), - }; - } - return policy; -} - -function materializeCapture(input: { - readonly familyId: string; - readonly binding: FamilyBindingRecord; - readonly projection: BehaviourProjection; - readonly location: RuntimeIdentity; - readonly live?: EffectiveRuntimeConfig | undefined; -}): FamilyCapture { - const snapshotId = input.binding.snapshotHash.slice(0, 16); - const modelSettings = input.live?.modelSettings ?? []; - return { - schemaVersion: 1, - snapshotId, - integrity: input.binding.snapshotHash, - familyId: input.familyId, - capturedAt: Date.parse(input.binding.boundAt) || 0, - location: input.location, - roles: input.projection.policy.roles, - roleModels: input.projection.roleModels, - agents: input.projection.agentBindings, - variants: buildVariants(snapshotId, input.projection.roleModels, modelSettings), - // Native overlays can carry credentials, so they are re-derived from the live registry. - modelSettings, - vvoc: policyWithLiveCredentials(input.projection, input.live), - intent: { mode: "implicit", source: "config" }, - }; -} - -// START_BLOCK_LINEAGE_RESOLUTION -/** Resolve a session's family root through host-verified lineage. */ -export async function resolveSessionFamily(input: { - readonly sessionID: string; - readSession(sessionID: string): Promise; -}): Promise { - const seen = new Set(); - let current = input.sessionID; - for (let hop = 0; hop < MAX_LINEAGE_HOPS; hop += 1) { - if (seen.has(current)) { - throw new SnapshotAdmissionError("Session lineage contains a cycle; refusing to bind."); - } - seen.add(current); - const view = await input.readSession(current); - if (view === undefined) { - throw new SnapshotAdmissionError(`Session ${current} could not be read from the host.`); - } - const parent = - typeof view.parentID === "string" && view.parentID.length > 0 - ? view.parentID - : typeof view.forkSessionID === "string" && view.forkSessionID.length > 0 - ? view.forkSessionID - : undefined; - if (parent === undefined) return current; - current = parent; - } - throw new SnapshotAdmissionError("Session lineage exceeds the supported depth."); -} -// END_BLOCK_LINEAGE_RESOLUTION - -// START_BLOCK_BINDING_LIFECYCLE -/** Build the credential-safe family-binding service for one native context. */ -export function createSnapshotService(deps: SnapshotServiceDeps): SnapshotService { - const now = deps.now ?? Date.now; - const readPendingRebinds = deps.rebindRequests ?? listRebindRequests; - const familyLocks = new Map>(); - const sessionFamilies = new Map(); - let disposed = false; - - const diagnose = (message: string): void => { - // Messages are static and bounded: never include paths, parsed state, or credentials. - deps.diagnostic?.(message.slice(0, 240)); - }; - - function withFamilyLock(familyId: string, run: () => Promise): Promise { - if (deps.coordination !== undefined) return deps.coordination.withFamilyLock(familyId, run); - const previous = familyLocks.get(familyId) ?? Promise.resolve(); - const next = previous.then(run, run); - familyLocks.set( - familyId, - next.then( - () => undefined, - () => undefined, - ), - ); - return next; - } - - const familyOf = async (sessionID: string): Promise => { - const familyId = await resolveSessionFamily({ sessionID, readSession: deps.readSession }); - sessionFamilies.set(sessionID, familyId); - return familyId; - }; - - async function readBound( - familyId: string, - ): Promise< - { readonly binding: FamilyBindingRecord; readonly projection: BehaviourProjection } | undefined - > { - try { - const binding = await deps.store.readBinding(familyId); - if (binding === undefined || binding.familyId !== familyId) { - if (binding !== undefined) - diagnose("Invalid family binding pointer; rebinding on next workload."); - return undefined; - } - const projection = await deps.store.readSnapshot(binding.snapshotHash); - if (projection === undefined) { - diagnose("Missing or invalid family policy snapshot; rebinding on next workload."); - return undefined; - } - return { binding, projection }; - } catch { - diagnose("Family binding state could not be read; rebinding on next workload."); - return undefined; - } - } - - async function liveConfig(directory: string): Promise { - try { - return await deps.loadConfig(directory); - } catch { - diagnose("Live configuration could not be read; using the credential-free bound policy."); - return undefined; - } - } - - async function consumePendingRebind(input: { - readonly familyId: string; - readonly sessionID: string; - readonly directory: string; - readonly boundAt: number; - }): Promise< - | { - readonly binding: FamilyBindingRecord; - readonly projection: BehaviourProjection; - readonly config: EffectiveRuntimeConfig; - } - | undefined - > { - let requests: readonly RebindRequest[]; - try { - requests = await readPendingRebinds(); - } catch { - return undefined; - } - const match = requests.find( - (candidate) => - rebindRequestMatches(candidate, { - directory: input.directory, - sessionId: input.sessionID, - }) && candidate.requestedAt > input.boundAt, - ); - if (match === undefined) return undefined; - const config = await liveConfig(input.directory); - if (config === undefined) { - diagnose("A pending rebind request could not read the current configuration."); - return undefined; - } - try { - const projection = buildBehaviourProjection({ - roles: config.roles, - agentRoles: config.agentRoles, - vvoc: config.vvoc, - }); - const contentHash = await deps.store.writeSnapshot(projection); - const binding: FamilyBindingRecord = { - familyId: input.familyId, - snapshotHash: contentHash, - boundAt: new Date(now()).toISOString(), - }; - await deps.store.writeBinding(binding); - await collectGarbage(); - return { binding, projection, config }; - } catch { - diagnose("A pending rebind request could not be applied."); - return undefined; - } - } - - async function boundPolicy(sessionID: string): Promise { - if (disposed) return undefined; - let familyId: string; - try { - familyId = await familyOf(sessionID); - } catch { - return undefined; - } - const bound = await readBound(familyId); - if (bound === undefined) return undefined; - const session = await deps.readSession(familyId).catch(() => undefined); - const location = { - ...deps.location, - ...(session?.locationDirectory === undefined ? {} : { directory: session.locationDirectory }), - }; - const live = await liveConfig(location.directory); - return materializeCapture({ - familyId, - ...bound, - location, - ...(live === undefined ? {} : { live }), - }); - } - - async function bind( - request: SnapshotAdmissionRequest, - materialize: ((capture: FamilyCapture, selection: ModelSelection) => Promise) | undefined, - ): Promise { - if (disposed) { - return { - status: "rejected", - familyId: request.sessionID, - error: "Snapshot service is disposed.", - rollback: "none", - }; - } - let familyId: string; - try { - familyId = await familyOf(request.sessionID); - } catch (error) { - return { - status: "rejected", - familyId: request.sessionID, - error: String(error), - rollback: "none", - }; - } - return withFamilyLock(familyId, async () => { - const existing = await readBound(familyId); - if (existing !== undefined) { - const rebound = await consumePendingRebind({ - familyId, - sessionID: request.sessionID, - directory: request.directory, - boundAt: Date.parse(existing.binding.boundAt), - }); - const bound = rebound ?? existing; - const config = rebound?.config ?? (await liveConfig(request.directory)); - if (config !== undefined) { - const capture = materializeCapture({ - familyId, - binding: bound.binding, - projection: bound.projection, - location: request.location, - live: config, - }); - // Align the session to the family selection on every work item, so a - // child created before its family bound (forks) resolves the same - // qualified model instead of a stale native default. - const base = request.explicit ?? request.selectionOverride ?? primarySelection(capture); - const selection = base === undefined ? undefined : qualifySelection(capture, base); - if (selection !== undefined && request.force !== false) { - await materialize?.(capture, selection); - await deps.switchModel({ sessionID: request.sessionID, model: selection }); - } - if (rebound !== undefined) { - return { - status: "bound", - familyId, - snapshotId: capture.snapshotId, - ...(selection === undefined ? {} : { selection }), - rollback: "none", - }; - } - } - return { - status: "reused", - familyId, - snapshotId: bound.binding.snapshotHash.slice(0, 16), - }; - } - const config = request.admissionConfig ?? (await liveConfig(request.directory)); - if (config === undefined) { - return { - status: "rejected", - familyId, - error: "Current configuration is unavailable; family binding was not changed.", - rollback: "none", - }; - } - try { - const projection = buildBehaviourProjection({ - roles: config.roles, - agentRoles: config.agentRoles, - vvoc: config.vvoc, - }); - const hash = behaviourContentHash(projection); - await deps.store.writeSnapshot(projection); - const binding: FamilyBindingRecord = { - familyId, - snapshotHash: hash, - boundAt: new Date(now()).toISOString(), - }; - await deps.store.writeBinding(binding); - const capture = materializeCapture({ - familyId, - binding, - projection, - location: request.location, - live: config, - }); - const base = request.explicit ?? request.selectionOverride ?? primarySelection(capture); - const selection = base === undefined ? undefined : qualifySelection(capture, base); - if (selection !== undefined && request.force !== false) { - await materialize?.(capture, selection); - await deps.switchModel({ sessionID: request.sessionID, model: selection }); - } - return { - status: "bound", - familyId, - snapshotId: capture.snapshotId, - ...(selection === undefined ? {} : { selection }), - rollback: "none", - }; - } catch { - diagnose("Family binding could not be written; the affected family remains unbound."); - return { - status: "rejected", - familyId, - error: "Family binding could not be persisted.", - rollback: "none", - }; - } - }); - } - - async function collectGarbage(): Promise { - try { - const counts = await deps.store.countBindings(); - for (const hash of await deps.store.listSnapshots()) { - if ((counts.get(hash) ?? 0) === 0) await deps.store.removeSnapshot(hash); - } - } catch { - diagnose("Family binding cleanup was deferred after a store failure."); - } - } - - const auxiliary = createAuxiliaryService({ - familyOf, - policy: boundPolicy, - session: deps.auxiliarySession, - }); - - const service: SnapshotService = { - familyOf, - policy: boundPolicy, - configFor: boundPolicy, - captures: async () => { - if (disposed) return []; - const captures: FamilyCapture[] = []; - for (const binding of await deps.store.listBindings().catch(() => [])) { - const bound = await readBound(binding.familyId); - if (bound === undefined) continue; - const live = await liveConfig(deps.location.directory); - captures.push( - materializeCapture({ - familyId: binding.familyId, - ...bound, - location: deps.location, - ...(live === undefined ? {} : { live }), - }), - ); - } - return captures; - }, - variants: async (familyId?: string): Promise => { - const captures = await (async () => { - if (familyId === undefined) return await service.captures(); - const capture = await boundPolicy(familyId); - return capture === undefined ? [] : [capture]; - })(); - return captures.flatMap((capture) => - capture.variants.map((variant) => ({ - providerID: variant.providerID, - modelID: variant.modelID, - variant, - })), - ); - }, - // Plugins still call this after their config lookup. Binding is now performed - // by the prompt/owned-work gateway, so this compatibility shim never stages. - accept: async (input: SnapshotAcceptRequest) => { - const capture = await boundPolicy(input.sessionID); - if (capture !== undefined) { - return { status: "reused", familyId: capture.familyId, snapshotId: capture.snapshotId }; - } - return { - status: "rejected", - familyId: input.sessionID, - error: "No family binding exists for this workload.", - rollback: "none", - }; - }, - admitOwned: async (request, materialize) => { - return bind(request, materialize); - }, - rebind: async ({ directory, location, familyId }) => { - if (disposed) return []; - // FileBindingStore is project-scoped; reject a caller that tries to cross it. - if (location.projectID !== deps.location.projectID) return []; - const config = await liveConfig(directory); - if (config === undefined) return []; - let projection: BehaviourProjection; - try { - projection = buildBehaviourProjection({ - roles: config.roles, - agentRoles: config.agentRoles, - vvoc: config.vvoc, - }); - } catch { - diagnose("Current configuration could not be projected for rebinding."); - return []; - } - try { - const hash = await deps.store.writeSnapshot(projection); - const matching = (await deps.store.listBindings()).filter( - (binding) => familyId === undefined || binding.familyId === familyId, - ); - await Promise.all( - matching.map((binding) => - withFamilyLock(binding.familyId, () => - deps.store.writeBinding({ - familyId: binding.familyId, - snapshotHash: hash, - boundAt: new Date(now()).toISOString(), - }), - ), - ), - ); - await collectGarbage(); - return matching.map((binding) => binding.familyId); - } catch { - diagnose("Family rebinding could not be completed."); - return []; - } - }, - removeFamily: async (familyId) => { - if (disposed) return; - const resolvedFamilyId = sessionFamilies.get(familyId) ?? familyId; - await withFamilyLock(resolvedFamilyId, async () => { - try { - await deps.store.removeBinding(resolvedFamilyId); - for (const [sessionID, knownFamilyId] of sessionFamilies) { - if (knownFamilyId === resolvedFamilyId) sessionFamilies.delete(sessionID); - } - } catch { - diagnose("Family binding deletion was deferred after a store failure."); - return; - } - await collectGarbage(); - }); - }, - auxiliary, - dispose() { - disposed = true; - familyLocks.clear(); - sessionFamilies.clear(); - }, - }; - return service; -} -// END_BLOCK_BINDING_LIFECYCLE diff --git a/src/runtime/types.ts b/src/runtime/types.ts index 591c0347..ab82a61a 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -1,8 +1,8 @@ // FILE: src/runtime/types.ts // VERSION: 1.0.0 // START_MODULE_CONTRACT -// PURPOSE: Define the shared native runtime vocabulary: the supported host window and service-compatibility predicate, structural plugin-context seams, resource-permission contracts, and durable family-binding/materialized-policy/auxiliary contracts consumed by native plugins. -// SCOPE: Type-only native contracts plus pure location/identity/key helpers and error constructors. Durable family bindings are credential-free projections; consumers receive a live-credential materialization. No service discovery, authentication, RPC registration, permission flow, snapshot persistence, filesystem, or network I/O. +// PURPOSE: Define the shared native runtime vocabulary: the supported host window and service-compatibility predicate, structural plugin-context seams, resource-permission contracts, and the session/auxiliary/agent-binding contracts consumed by native plugins. +// SCOPE: Type-only native contracts plus pure location/identity/key helpers and error constructors. No service discovery, authentication, RPC registration, permission flow, settings persistence, filesystem, or network I/O. // DEPENDS: [@opencode/plugin, @opencode/client, @opencode/schema/agent, @opencode/schema/location, @opencode/schema/permission, @opencode/schema/rpc, @opencode/schema/session, src/lib/host-compat.ts] // LINKS: [M-NATIVE-RUNTIME, V-M-NATIVE-RUNTIME] // ROLE: TYPES @@ -39,49 +39,27 @@ // PERMISSION_REPLIED_EVENT - Native event type carrying a terminal permission reply. // SERVER_CONNECTED_EVENT - Native event proving the live event transport finished its handshake. // ModelSelection - Concrete native provider/model/variant selection. -// ModelVariantCapture - In-memory model variant overlay materialized as a native variant. -// CapturedVvocConfig - Config-shaped vvoc policy value used by a materialized family capture. -// CapturedModelSettings - Live native model/provider overlay re-derived for a materialized capture. -// CaptureIntent - Selection provenance, including legacy-store decoding values. -// CaptureIntentMode - Legacy selection-intent mode union retained for store decoding. -// CaptureIntentSource - Legacy selection-intent source union retained for store decoding. +// CapturedVvocConfig - Config-shaped vvoc policy value used by a resolved family view. // PermissionCreateInput - Minimal input accepted by the native permission create call. // AgentPolicyBinding - Captured agent-to-role and resolved-model binding. -// FamilyCapture - In-memory materialization of a durable credential-free family binding. -// StagedCandidate - Legacy storage-only candidate type retained for the unused legacy file-store implementation. -// SnapshotStore - Legacy storage-only interface retained for the unused legacy file-store implementation. -// AcceptedInput - Legacy accepted-input record retained for store decoding. -// AcceptedInputReconciliation - Legacy accepted-input reconciliation record retained for store decoding. -// stagedCandidateIdentity - Legacy candidate identity helper retained for store decoding. -// stagedCandidateKey - Legacy candidate key helper retained for store decoding. -// SnapshotAcceptRequest - Compatibility input for plugins that still invoke retired acceptance. // NativeSessionView - Structural native session view used for host-verified lineage. -// ModelEditorLike - Structural native model transform editor used for variant materialization. -// AgentEditorLike - Structural native agent transform editor used for snapshot-bound role selection. -// VariantRegistration - Snapshot-qualified variant to publish on one real provider/model. -// AdmissionOutcome - Result of one awaited family admission attempt. -// EffectiveRuntimeConfig - Structural effective vvoc policy input to the snapshot service. -// SnapshotAdmissionRequest - Per-workload bind-on-first-work request. -// SnapshotService - Family-binding/config/model/auxiliary service consumed by later plugins. -// SnapshotLease - Per-acquisition release handle for a shared snapshot service. +// ModelEditorLike - Structural native model transform editor used for default-model overrides. +// AgentEditorLike - Structural native agent transform editor used for role-policy application. +// EffectiveRuntimeConfig - Structural effective vvoc policy input consumed by the runtime. // AuxiliaryMessageContent - Text carried by a native title request as generation input. // AuxiliarySessionApi - Structural native child-session/generate boundary for auxiliary work. // AuxiliaryWorkloadMetadata - Host-owned auxiliary workload metadata recorded on a child session. // decodeAuxiliaryMetadata - Decode the auxiliary workload metadata from native session metadata. // NativeSessionImportPayload - Typed payload forwarded to the native import route for a parented auxiliary child. // decodeImportedSessionID - Decode the id of an imported native session from the unchecked client result. -// AuxiliaryService - Snapshot-bound auxiliary generation consumed by title and Guardian. -// SnapshotStoreError - Durable capture state was corrupt or unreadable, so work must fail closed. -// SnapshotAdmissionError - An awaited admission failed and no policy was published. -// SnapshotUnboundError - A dispatch was refused because no family policy was bound. +// AuxiliaryService - Family-settings-bound auxiliary generation consumed by title and Guardian. // runtimeIdentity - Build the stable identity from a runtime location. // runtimeKey - Render a deterministic location identity string for diagnostics. // END_MODULE_MAP // // START_CHANGE_SUMMARY -// LAST_CHANGE: [C-HOST-COMPAT-WINDOW T-002 - Replaced the exact SUPPORTED_SERVICE_VERSION string with the host-compat window predicate: discovery now accepts any stable host inside the supported major-bounded window, and DeepMutable is owned locally because the host package no longer ships @opencode/plugin/promise/types.] -// PREVIOUS: [C-SNAPSHOT-ANCHORING-REDESIGN T-004/T-005 - Replaced runtime staged-admission contracts with durable credential-free family bindings, explicit rebind, and deletion cleanup while retaining accept/admitOwned compatibility entry points for plugins.] -// PREVIOUS: [C-OPENCODE-V2-NATIVE T-009 - Added the immutable caller-supplied admission config to SnapshotAdmissionRequest and the native app coordination identity to RuntimeContext.] +// LAST_CHANGE: [C-SESSION-OWNED-SETTINGS T-002 - Removed the snapshot vocabulary (family capture, staged candidates, admission/guard/store contracts and error classes) and slimmed EffectiveRuntimeConfig; session-owned settings types live in session-settings.ts.] +// PREVIOUS: [C-HOST-COMPAT-WINDOW T-002 - Replaced the exact SUPPORTED_SERVICE_VERSION string with the host-compat window predicate: discovery now accepts any stable host inside the supported major-bounded window, and DeepMutable is owned locally because the host package no longer ships @opencode/plugin/promise/types.] // END_CHANGE_SUMMARY import type { @@ -410,48 +388,9 @@ export interface ModelSelection { readonly variant?: string | undefined; } -/** Persisted model variant overlay materialized as a native model variant. */ -export interface ModelVariantCapture { - /** Snapshot-qualified native variant id: `.[.]`. */ - readonly id: string; - readonly providerID: string; - readonly modelID: string; - /** Source role variant (for example `thinking`) that produced this overlay. */ - readonly sourceVariant?: string | undefined; - readonly settings?: Readonly> | undefined; - readonly body?: Readonly> | undefined; - readonly headers?: Readonly> | undefined; -} - /** Deep-copied, schema-validated effective vvoc configuration captured for replay. */ export type CapturedVvocConfig = VvocConfig; -/** Captured native model/provider overlay needed to replay an actual request payload. */ -export interface CapturedModelSettings { - readonly providerID: string; - readonly modelID: string; - readonly variant?: string | undefined; - readonly settings?: Readonly> | undefined; - readonly body?: Readonly> | undefined; - readonly headers?: Readonly> | undefined; -} - -/** Provenance of a captured selection: how the effective model was chosen. */ -export type CaptureIntentMode = "explicit" | "implicit" | "staged"; - -/** Native source that produced a captured selection. */ -export type CaptureIntentSource = "config" | "switch" | "watcher" | "preset"; - -/** Explicit/implicit/staged provenance of a captured selection, with raw role intent preserved. */ -export interface CaptureIntent { - readonly mode: CaptureIntentMode; - readonly source: CaptureIntentSource; - /** Raw vv-role reference when the selection originated from a role binding. */ - readonly roleRef?: string | undefined; - /** Literal selection when the user or config supplied a concrete provider/model. */ - readonly literal?: ModelSelection | undefined; -} - /** Captured agent-to-role and resolved-model binding. */ export interface AgentPolicyBinding { readonly agentID: string; @@ -459,123 +398,6 @@ export interface AgentPolicyBinding { readonly selection?: ModelSelection | undefined; } -/** In-memory materialization of a credential-free durable policy binding for one session family. */ -export interface FamilyCapture { - readonly schemaVersion: 1; - readonly snapshotId: string; - /** Content digest over every behavior-relevant capture field, verified on read. */ - readonly integrity: string; - readonly familyId: string; - readonly capturedAt: number; - readonly location: RuntimeIdentity; - readonly roles: Readonly>; - readonly roleModels: Readonly>; - readonly agents: ReadonlyArray; - readonly variants: ReadonlyArray; - readonly modelSettings: ReadonlyArray; - readonly vvoc: CapturedVvocConfig; - /** Raw OpenCode root/small_model/agent/command model intent preserved before normalization. */ - readonly rawIntent?: RawOpenCodeModelIntent | undefined; - /** Implicit root selection from raw OpenCode root model intent, below an explicit choice. */ - readonly rootSelection?: ModelSelection | undefined; - readonly modelOverride?: ModelSelection | undefined; - readonly intent: CaptureIntent; -} - -/** Persisted pre-dispatch admission candidate. */ -export interface StagedCandidate { - readonly familyId: string; - /** Session whose awaited switch applied this candidate, used for commit-failure rollback. */ - readonly sessionID: string; - /** - * Expected accepted-input identity: the native messageID supplied by the - * prompt hook. A commit is authorized only by an inbox enqueue with this exact - * inboxID (or a durable log replay proving it), never by family alone. - */ - readonly inboxID?: string | undefined; - /** - * Owner token for an owned generated-work operation (for example an auxiliary - * child or a direct generate). Prompt candidates carry `inboxID`; owned - * candidates carry `operationID`; the two are never interchangeable. - */ - readonly operationID?: string | undefined; - /** Workload the candidate was staged for (`prompt`, `generate`, ...). */ - readonly workload?: string | undefined; - readonly stagedAt: number; - /** Capture content revision the candidate was staged for; a newer revision supersedes it. */ - readonly revision: string; - /** Fully qualified selection (including captured variant) applied by the awaited switch. */ - readonly selection: ModelSelection; - /** Session model before the switch, used for conditional rollback. */ - readonly before?: ModelSelection | undefined; - readonly capture: FamilyCapture; -} - -/** Durable per-family capture and staged-candidate persistence contract. */ -export interface SnapshotStore { - read(familyId: string): Promise; - write(familyId: string, capture: FamilyCapture): Promise; - remove(familyId: string): Promise; - /** All staged candidates for a family, in durable file order. */ - readCandidates(familyId: string): Promise; - /** Insert or update the candidate with the same {@link stagedCandidateKey}. */ - writeCandidate(candidate: StagedCandidate): Promise; - /** Remove exactly the candidate with this key; leaves other owners intact. */ - removeCandidate(familyId: string, candidateKey: string): Promise; - /** Remove every staged candidate for a family (used once a family is bound). */ - removeCandidates(familyId: string): Promise; - /** Durable marker proving a family was once bound, so a missing capture fails closed. */ - readMarker(familyId: string): Promise; - writeMarker(familyId: string): Promise; - removeMarker(familyId: string): Promise; - list(): Promise; -} - -/** Stable identity of one staged candidate within a family. */ -export function stagedCandidateIdentity(input: { - readonly sessionID: string; - readonly inboxID?: string | undefined; - readonly operationID?: string | undefined; - readonly workload?: string | undefined; -}): string { - return [input.sessionID, input.inboxID ?? "", input.operationID ?? "", input.workload ?? ""].join( - "\u0000", - ); -} - -/** Stable identity of one staged candidate within a family. */ -export function stagedCandidateKey(candidate: StagedCandidate): string { - return stagedCandidateIdentity(candidate); -} - -/** - * One native accepted input reconciled from the live stream or the durable - * session log, with the native ordering signals used to pick the first accepted - * workload of a family. - */ -export interface AcceptedInput { - readonly sessionID: string; - readonly inboxID: string; - readonly itemType: string; - /** Native event creation time in epoch milliseconds. */ - readonly created: number; - /** Native per-aggregate durable sequence, when supplied. */ - readonly seq?: number | undefined; - /** Whether this entry came from a verified live event or the durable log replay. */ - readonly source: "live" | "log"; -} - -/** - * Accepted-input reconciliation for one session. `complete` is true only when - * the durable log replay reached a verified matching synced watermark; an - * incomplete result may still carry verified live events but must not be used to - * order multiple candidates. - */ -export interface AcceptedInputReconciliation { - readonly inputs: readonly AcceptedInput[]; - readonly complete: boolean; -} - /** Structural native session view used for host-verified lineage and provenance. */ export interface NativeSessionView { readonly id: string; @@ -596,9 +418,9 @@ export interface NativeSessionView { } /** - * Structural native model transform editor used for variant materialization. The - * native `ModelEditor` callback parameter satisfies it exactly, so plugin code - * passes the native editor without a cast. + * Structural native model transform editor used for default-model overrides. + * The native `ModelEditor` callback parameter satisfies it exactly, so plugin + * code passes the native editor without a cast. */ export interface ModelEditorLike { list(providerID?: string): readonly DeepMutable[]; @@ -616,8 +438,8 @@ export interface ModelEditorLike { } /** - * Structural native agent transform editor used for snapshot-bound role - * selection. The native `AgentEditor` callback parameter satisfies it exactly. + * Structural native agent transform editor used for role-policy application. + * The native `AgentEditor` callback parameter satisfies it exactly. */ export interface AgentEditorLike { list(): readonly DeepMutable[]; @@ -625,36 +447,16 @@ export interface AgentEditorLike { update(id: string, update: (agent: DeepMutable) => void): void; } -/** Snapshot-qualified variant to publish on one real provider/model. */ -export interface VariantRegistration { - readonly providerID: string; - readonly modelID: string; - readonly variant: ModelVariantCapture; -} - -/** Result of one family bind-on-first-work attempt. */ -export interface AdmissionOutcome { - readonly status: "bound" | "reused" | "rejected"; - readonly familyId: string; - readonly snapshotId?: string | undefined; - readonly selection?: ModelSelection | undefined; - /** Retained as an outcome shape compatibility field; binding does not roll back. */ - readonly rollback?: "none" | undefined; - readonly error?: string | undefined; -} - -/** Structural effective vvoc policy input to the snapshot service. */ +/** Structural effective vvoc policy input consumed by the runtime. */ export interface EffectiveRuntimeConfig { readonly roles: Readonly>; /** Agent-to-role bindings managed by vvoc for built-in and bundled agents. */ readonly agentRoles: Readonly>; - /** Full effective vvoc document captured for replay. */ + /** Full effective vvoc document available to consumers. */ readonly vvoc: CapturedVvocConfig; /** Native agent bindings (including custom agents) observed from the host editor. */ readonly agentBindings?: ReadonlyArray | undefined; - /** Native model/provider overlays observed from the host editor. */ - readonly modelSettings?: ReadonlyArray | undefined; - /** Current host default model, used for initial-selection provenance. */ + /** Current host default model. */ readonly nativeDefault?: ModelSelection | undefined; /** Raw OpenCode model intent preserved before native normalization. */ readonly rawIntent?: RawOpenCodeModelIntent | undefined; @@ -663,83 +465,6 @@ export interface EffectiveRuntimeConfig { readonly sourcePath?: string | undefined; } -/** Per-workload bind-on-first-work request. */ -export interface SnapshotAdmissionRequest { - readonly sessionID: string; - readonly directory: string; - /** Runtime identity of the host location owning this workload. */ - readonly location: RuntimeIdentity; - /** User's explicit native selection, preserved over any implicit default. */ - readonly explicit?: ModelSelection | undefined; - /** - * Base selection to bind when no explicit user choice exists. Used by callers - * that must bind the session's current model without marking it user-explicit - * (for example when role overriding is disabled). Falls back to the capture's - * primary role selection. - */ - readonly selectionOverride?: ModelSelection | undefined; - readonly intent?: CaptureIntent | undefined; - /** When false, bind policy without forcing a native model switch. */ - readonly force?: boolean | undefined; - /** - * One immutable admission config assembled by the caller from a single coherent - * read (fresh vvoc + raw intent + provenance-aware native agent inputs + - * bindings + overlays). When present the service derives the first-work target - * and durable projection from this exact value instead of re-reading config, - * so a concurrent vvoc change cannot make them disagree. - */ - readonly admissionConfig?: EffectiveRuntimeConfig | undefined; -} - -/** Compatibility input for plugins that call the retired acceptance layer. */ -export interface SnapshotAcceptRequest { - readonly sessionID: string; -} - -/** Documented family-binding/config/model/auxiliary service consumed by later plugins. */ -export interface SnapshotService { - /** Host-verified family root session id for a session. */ - familyOf(sessionID: string): Promise; - /** Materialized policy bound to a session's family, or undefined when unbound. */ - policy(sessionID: string): Promise; - /** Materialized bound configuration with live credentials and overlays reattached. */ - configFor(sessionID: string): Promise; - /** All bound-family materializations known to this service. */ - captures(): Promise; - /** Snapshot-qualified variants derived from every bound materialization, optionally one family. */ - variants(familyId?: string): Promise; - /** Compatibility no-op for plugins that still ask the retired admission layer to accept work. */ - accept(input: SnapshotAcceptRequest): Promise; - /** - * Compatibility-named bind-on-first-work gateway. It serializes one family, - * writes a credential-free content-addressed binding, materializes live overlays, - * and switches only when requested. - */ - admitOwned( - request: SnapshotAdmissionRequest, - materialize?: (capture: FamilyCapture, selection: ModelSelection) => Promise, - ): Promise; - /** Replace matching family bindings with the current project revision. */ - rebind(input: { - readonly directory: string; - readonly location: RuntimeIdentity; - readonly familyId?: string | undefined; - }): Promise; - /** Remove one root-family pointer and garbage-collect unreferenced snapshots. */ - removeFamily(familyId: string): Promise; - /** Snapshot-bound auxiliary generation for title and Guardian work. */ - readonly auxiliary: AuxiliaryService; - /** Release service-owned state; never stops the host. */ - dispose(): void; -} - -/** Per-acquisition release handle for a shared snapshot service. */ -export interface SnapshotLease { - readonly snapshots: SnapshotService; - /** Idempotent for this lease; disposes the service only when the last lease releases. */ - release(): Promise; -} - /** * Structural native auxiliary-session/generate boundary. Production creates a * real parented native child through the authenticated full client's @@ -756,11 +481,11 @@ export interface AuxiliarySessionApi { readonly title: string; /** Workload kind recorded in host-owned metadata for restart recovery. */ readonly kind: "title" | "compaction" | "generate"; - /** Captured role whose selection the child is bound to, when known. */ + /** Family role whose selection the child is bound to, when known. */ readonly role?: string | undefined; - /** Fully qualified family selection applied to the child. */ + /** Resolved family selection applied to the child. */ readonly model: ModelSelection; - /** Optional captured agent id to bind. */ + /** Optional family agent id to bind. */ readonly agent?: string | undefined; }): Promise<{ readonly sessionID: string }>; switchModel(input: { readonly sessionID: string; readonly model: ModelSelection }): Promise; @@ -840,7 +565,7 @@ export interface AuxiliaryMessageContent { readonly text: string; } -/** Snapshot-bound auxiliary generation consumed by title and Guardian. */ +/** Family-settings-bound auxiliary generation consumed by title and Guardian. */ export interface AuxiliaryService { /** Resolve a title through a bound child; a set event.result always wins. */ title(event: { @@ -848,48 +573,18 @@ export interface AuxiliaryService { result?: string | undefined; /** Native title messages retained as generation input, never dropped. */ readonly messages?: ReadonlyArray | undefined; - /** Captured role to use (title/small_model), never an implicit primary fallback. */ + /** Family role to use (title/small_model), never an implicit primary fallback. */ readonly role?: string | undefined; }): Promise; - /** Run snapshot-bound auxiliary generation for the given request kind and role. */ + /** Run settings-bound auxiliary generation for the given request kind and role. */ generate(input: { readonly sessionID: string; readonly kind: "title" | "compaction" | "generate"; readonly prompt: string; - /** Captured role to use (for example Guardian's fast role); defaults to primary. */ + /** Family role to use (for example Guardian's fast role); defaults to primary. */ readonly role?: string | undefined; }): Promise<{ readonly text: string } | undefined>; } - -/** Durable capture state was corrupt or unreadable, so work must fail closed. */ -export class SnapshotStoreError extends Error { - readonly code = "SNAPSHOT_STORE_INVALID"; - - constructor(message: string, options?: ErrorOptions) { - super(message, options); - this.name = "SnapshotStoreError"; - } -} - -/** An awaited admission failed and no policy was published. */ -export class SnapshotAdmissionError extends Error { - readonly code = "SNAPSHOT_ADMISSION_FAILED"; - - constructor(message: string, options?: ErrorOptions) { - super(message, options); - this.name = "SnapshotAdmissionError"; - } -} - -/** A dispatch was refused because no family policy was bound. */ -export class SnapshotUnboundError extends Error { - readonly code = "SNAPSHOT_UNBOUND"; - - constructor(message = "No family snapshot is bound for this workload.") { - super(message); - this.name = "SnapshotUnboundError"; - } -} // END_BLOCK_SNAPSHOT_VOCABULARY // START_BLOCK_IDENTITY_HELPERS diff --git a/src/tui.tsx b/src/tui.tsx index c76a11f5..deafb5b7 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -1,9 +1,9 @@ // FILE: src/tui.tsx -// VERSION: 2.1.0 +// VERSION: 3.0.0 // START_MODULE_CONTRACT -// PURPOSE: Publish the default @osovv/vv-opencode/tui native plugin module containing the /context inspector, /vv-rebind command, analytics indicator, branding footer, and peak-hours banner. -// SCOPE: Native plugin definition (id/setup/cleanup), a shared selection-keyed policy controller over the context-inspection RPC that resolves the current selected session's native location, refresh on route and native policy-binding events, the /vv-rebind marker facade, and lifecycle cleanup of every slot/keymap/effect/subscription. It performs no local filesystem config read. -// DEPENDS: [@opencode/plugin/tui, solid-js, src/runtime/context-inspection-contract.ts, src/tui/policy.ts, src/tui/context/collect.ts, src/tui/context/plugin.ts, src/tui/context/view.tsx, src/tui/rebind/plugin.ts, src/tui/analytics/indicator.tsx, src/tui/branding/footer.tsx, src/tui/peak-hours/banner.tsx] +// PURPOSE: Publish the default @osovv/vv-opencode/tui native plugin module containing the /context inspector, /vv-settings command, analytics indicator, branding footer, and peak-hours banner. +// SCOPE: Native plugin definition (id/setup/cleanup), a shared selection-keyed policy controller over the context-inspection RPC that resolves the current selected session's native location, refresh on route and native policy events, the /vv-settings explicit family-settings facade, and lifecycle cleanup of every slot/keymap/effect/subscription. It performs no local filesystem config read. +// DEPENDS: [@opencode/plugin/tui, solid-js, src/runtime/context-inspection-contract.ts, src/tui/policy.ts, src/tui/context/collect.ts, src/tui/context/plugin.ts, src/tui/context/view.tsx, src/tui/settings/plugin.ts, src/tui/analytics/indicator.tsx, src/tui/branding/footer.tsx, src/tui/peak-hours/banner.tsx] // LINKS: [M-PLUGIN-CONTEXT-TUI, M-TUI-ANALYTICS-INDICATOR, M-TUI-BRANDING-FOOTER, M-TUI-PEAK-HOURS-BANNER, DF-CONTEXT-INSPECTION, V-M-PLUGIN-CONTEXT-TUI] // ROLE: BARREL // MAP_MODE: SUMMARY @@ -11,11 +11,11 @@ // // START_MODULE_MAP // vvocContextTuiPlugin - Named native TUI plugin definition for direct consumers and tests. -// default - Native OpenCode TUI plugin module registering /context, /vv-rebind, the analytics indicator, the branding footer, and the peak-hours banner. +// default - Native OpenCode TUI plugin module registering /context, /vv-settings, the analytics indicator, the branding footer, and the peak-hours banner. // END_MODULE_MAP // // START_CHANGE_SUMMARY -// LAST_CHANGE: [C-VV-REBIND-TUI-COMMAND T-001 - Registered /vv-rebind beside /context as a thin facade over the rebind request marker.] +// LAST_CHANGE: [C-SESSION-OWNED-SETTINGS T-005 - Replaced the /vv-rebind marker facade with the /vv-settings explicit family-settings command.] // END_CHANGE_SUMMARY import { Plugin } from "@opencode/plugin/tui"; @@ -27,7 +27,7 @@ import { collectContextAnalysis, createTuiCollectionDependencies } from "./tui/c import { activeSessionID, registerContextTuiPlugin } from "./tui/context/plugin.js"; import { openContextDialog } from "./tui/context/view.js"; import { registerPeakHoursBanner } from "./tui/peak-hours/banner.js"; -import { registerRebindTuiCommand } from "./tui/rebind/plugin.js"; +import { registerSettingsTuiCommand } from "./tui/settings/plugin.js"; import { createPolicyController, createRpcPolicyFetch, @@ -65,12 +65,12 @@ export const vvocContextTuiPlugin = Plugin.define({ void controller.refresh({ sessionID, directory: sessionDirectory(sessionID) }); }; createEffect(refreshActive); - // An initially unbound/empty tab becomes captured without a tab change. - // The family capture is committed from accepted-event handling, which can - // lag the TUI's `session.inbox.enqueued`, so refresh again on native step + // An initially unborn/empty tab gains its settings without a tab change. + // The family settings are committed on first resolution, which can lag the + // TUI's `session.inbox.enqueued`, so refresh again on native step // boundaries: pinned `session.step.started` is published when request // dispatch begins (after delivery) and `session.step.ended` after the step - // settles, both strictly later than the enqueue that triggers the commit. + // settles, both strictly later than the enqueue that triggers the birth. cleanups.push( ctx.data.on("session.inbox.enqueued", (event) => { if (activeSessionID(ctx) === event.data.sessionID) refreshActive(); @@ -97,7 +97,7 @@ export const vvocContextTuiPlugin = Plugin.define({ collect: (_context, sessionID) => collectContextAnalysis(sessionID, collection), open: (context, analysis) => openContextDialog(context, analysis), }), - registerRebindTuiCommand(ctx), + registerSettingsTuiCommand(ctx), registerAnalyticsIndicator(ctx, policy, ctx.options), registerBrandingFooter(ctx), registerPeakHoursBanner(ctx, policy), diff --git a/src/tui/rebind/plugin.test.ts b/src/tui/rebind/plugin.test.ts deleted file mode 100644 index d8d6494b..00000000 --- a/src/tui/rebind/plugin.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -// FILE: src/tui/rebind/plugin.test.ts -// VERSION: 1.0.0 -// START_MODULE_CONTRACT -// PURPOSE: Verify /vv-rebind native command registration, active-session gating, marker content, no-session handling, and bounded failures. -// SCOPE: Native keymap layer wiring with injected dependencies; the marker write is verified through the injected writer boundary. -// DEPENDS: [bun:test, @opencode/plugin/tui, src/tui/rebind/plugin.ts] -// LINKS: [M-PLUGIN-CONTEXT-TUI, V-M-PLUGIN-CONTEXT-TUI] -// ROLE: TEST -// MAP_MODE: LOCALS -// END_MODULE_CONTRACT -// -// START_MODULE_MAP -// CommandShape - Minimal captured command shape. -// MarkerInput - Marker writer input captured by the harness. -// createHarness - Build the TUI harness with a scripted route and marker writer. -// END_MODULE_MAP -// -// START_CHANGE_SUMMARY -// LAST_CHANGE: [C-VV-REBIND-TUI-COMMAND T-001 - Added /vv-rebind coverage for registration, gating, marker content, and bounded output.] -// END_CHANGE_SUMMARY - -import { describe, expect, test } from "bun:test"; -import type { Plugin } from "@opencode/plugin/tui"; -import { REBIND_COMMAND_ID, registerRebindTuiCommand } from "./plugin.js"; - -type CommandShape = { - id?: string; - group?: string; - slash?: { name: string }; - enabled: () => boolean; - run: (input?: string) => Promise | void; -}; - -type MarkerInput = { directory: string; sessionId: string }; - -function createHarness(initialRoute: { type: "session"; sessionID: string } | { type: "home" }) { - const toasts: Array<{ variant?: string; title?: string; message: string }> = []; - const markers: MarkerInput[] = []; - let route = initialRoute; - let layer: { commands?: CommandShape[] } | undefined; - let claim: { append?: string; render?: () => unknown } | undefined; - const context = { - ui: { - router: { current: () => route }, - toast: { - show: (toast: { variant?: string; title?: string; message: string }) => toasts.push(toast), - }, - slot: (value: { append?: string; render?: () => unknown }) => { - claim = value; - return () => undefined; - }, - }, - keymap: { - layer: (input: () => { commands?: CommandShape[] }) => { - layer = input(); - }, - }, - } as unknown as Plugin.Context; - const mount = () => { - claim?.render?.(); - }; - return { - context, - toasts, - markers, - layer: () => layer, - claim: () => claim, - mount, - setRoute: (next: typeof route) => { - route = next; - }, - }; -} - -describe("rebind TUI command", () => { - test("registers the vv-rebind slash command on the app slot", () => { - const harness = createHarness({ type: "session", sessionID: "session-1" }); - registerRebindTuiCommand(harness.context, { - writeMarker: async () => { - throw new Error("must not run"); - }, - directory: () => "/project", - }); - harness.mount(); - - expect(harness.claim()?.append).toBe("app"); - const commands = harness.layer()?.commands ?? []; - expect(commands).toHaveLength(1); - expect(commands[0]?.id).toBe(REBIND_COMMAND_ID); - expect(commands[0]?.group).toBe("VVOC"); - expect(commands[0]?.slash?.name).toBe("vv-rebind"); - expect(commands[0]?.enabled()).toBe(true); - }); - - test("writes exactly one marker naming the active session and working directory", async () => { - const harness = createHarness({ type: "session", sessionID: "session-1" }); - registerRebindTuiCommand(harness.context, { - writeMarker: async (input) => { - harness.markers.push(input); - return { id: "req-1", requestedAt: 1, directory: input.directory }; - }, - directory: () => "/project", - }); - harness.mount(); - - await harness.layer()?.commands?.[0]?.run(); - - expect(harness.markers).toEqual([{ directory: "/project", sessionId: "session-1" }]); - expect(harness.toasts).toEqual([ - { - variant: "success", - title: "Rebind session", - message: "Re-resolving the configuration from the next message.", - }, - ]); - }); - - test("is disabled and shows a bounded hint without an open session", async () => { - const harness = createHarness({ type: "home" }); - registerRebindTuiCommand(harness.context, { - writeMarker: async (input) => { - harness.markers.push(input); - return { id: "req-1", requestedAt: 1, directory: input.directory }; - }, - directory: () => "/project", - }); - harness.mount(); - - expect(harness.layer()?.commands?.[0]?.enabled()).toBe(false); - // The palette can still invoke a disabled command; the run path must not write. - await harness.layer()?.commands?.[0]?.run(); - - expect(harness.markers).toEqual([]); - expect(harness.toasts).toHaveLength(1); - expect(harness.toasts[0]?.variant).toBe("warning"); - expect(harness.toasts[0]?.message).toBe("Open a session before running /vv-rebind."); - }); - - test("a failed marker write renders one bounded error and no success toast", async () => { - const harness = createHarness({ type: "session", sessionID: "session-1" }); - registerRebindTuiCommand(harness.context, { - writeMarker: async () => { - throw new Error("marker directory is not writable"); - }, - directory: () => "/project", - }); - harness.mount(); - - await harness.layer()?.commands?.[0]?.run(); - - expect(harness.markers).toEqual([]); - expect(harness.toasts).toHaveLength(1); - expect(harness.toasts[0]?.variant).toBe("error"); - expect(harness.toasts[0]?.message).toBe("marker directory is not writable"); - }); - - test("an outcome after the user switched tabs renders nothing", async () => { - const harness = createHarness({ type: "session", sessionID: "session-1" }); - const finishWrite: Array<() => void> = []; - registerRebindTuiCommand(harness.context, { - writeMarker: (input) => - new Promise((resolve) => { - harness.markers.push(input); - finishWrite.push(() => - resolve({ id: "req-1", requestedAt: 1, directory: input.directory }), - ); - }), - directory: () => "/project", - }); - harness.mount(); - - const pending = harness.layer()?.commands?.[0]?.run(); - harness.setRoute({ type: "session", sessionID: "session-2" }); - finishWrite[0]?.(); - await pending; - - expect(harness.markers).toHaveLength(1); - expect(harness.toasts).toEqual([]); - }); -}); diff --git a/src/tui/rebind/plugin.ts b/src/tui/rebind/plugin.ts deleted file mode 100644 index c0dd5a78..00000000 --- a/src/tui/rebind/plugin.ts +++ /dev/null @@ -1,115 +0,0 @@ -// FILE: src/tui/rebind/plugin.ts -// VERSION: 1.0.0 -// START_MODULE_CONTRACT -// PURPOSE: Register the native OpenCode /vv-rebind slash command that rebinds the current session family to the current effective vvoc configuration on its next workload. -// SCOPE: Keymap command registration through the native app slot, active-session gating, one bounded toast per outcome, and a single atomic rebind-marker write through the shared writer with lifecycle disposal. No configuration parsing, no credential values, and no other filesystem access. -// DEPENDS: [@opencode/plugin/tui, src/lib/rebind-request.ts, src/tui/context/plugin.ts] -// LINKS: [M-PLUGIN-CONTEXT-TUI, M-NATIVE-RUNTIME, V-M-PLUGIN-CONTEXT-TUI] -// ROLE: RUNTIME -// MAP_MODE: EXPORTS -// END_MODULE_CONTRACT -// -// START_MODULE_MAP -// REBIND_COMMAND_ID - Stable native keymap command id behind /vv-rebind. -// RebindTuiDependencies - Injectable marker writer and working-directory resolver for focused tests. -// registerRebindTuiCommand - Register /vv-rebind on the native keymap layer. -// END_MODULE_MAP -// -// START_CHANGE_SUMMARY -// LAST_CHANGE: [C-VV-REBIND-TUI-COMMAND T-001 - Added /vv-rebind as a thin TUI facade over the existing rebind request marker consumed on the family's next workload.] -// END_CHANGE_SUMMARY - -import type { Plugin } from "@opencode/plugin/tui"; -import { writeRebindRequest, type RebindRequest } from "../../lib/rebind-request.js"; -import { activeSessionID } from "../context/plugin.js"; - -/** Stable native keymap command id behind the /vv-rebind slash command. */ -export const REBIND_COMMAND_ID = "vvoc.rebind.request"; - -/** Injectable marker writer and working-directory resolver for focused tests. */ -export type RebindTuiDependencies = { - readonly writeMarker: (input: { - readonly directory: string; - readonly sessionId: string; - }) => Promise; - readonly directory: () => string; -}; - -/** - * Register /vv-rebind on the native keymap layer, mirroring the /context - * registration: the layer must be created inside a mounted slot render, so the - * command claims the always-present `app` slot. Running the command with an - * open session writes exactly one project-scoped rebind request marker naming - * the active session; the existing lazy consumer applies it on the family's - * next workload. The command reads nothing else and displays no configuration - * content, so a rebind is safe to request from any session state. - */ -export function registerRebindTuiCommand( - ctx: Plugin.Context, - dependencies: RebindTuiDependencies = { - writeMarker: writeRebindRequest, - directory: () => process.cwd(), - }, -): () => void { - return ctx.ui.slot({ - append: "app", - render() { - ctx.keymap.layer(() => ({ - // Global so the command stays reachable from the prompt input mode, - // like /context; the default base layer is not. - mode: "global", - commands: [ - { - id: REBIND_COMMAND_ID, - title: "Rebind session policy", - description: - "Re-resolve the vvoc configuration for this session family from the next message", - group: "VVOC", - palette: true, - slash: { name: "vv-rebind" }, - enabled: () => activeSessionID(ctx) !== undefined, - run: async () => { - const sessionID = activeSessionID(ctx); - if (sessionID === undefined) { - // Reachable only through the palette when no session is open. - ctx.ui.toast.show({ - variant: "warning", - title: "Rebind session", - message: "Open a session before running /vv-rebind.", - }); - return; - } - try { - await dependencies.writeMarker({ - directory: dependencies.directory(), - sessionId: sessionID, - }); - // A user who switched tabs mid-write did not see the outcome. - if (activeSessionID(ctx) !== sessionID) return; - ctx.ui.toast.show({ - variant: "success", - title: "Rebind session", - message: "Re-resolving the configuration from the next message.", - }); - } catch (error) { - if (activeSessionID(ctx) !== sessionID) return; - ctx.ui.toast.show({ - variant: "error", - title: "Rebind unavailable", - message: boundedError(error), - }); - } - }, - }, - ], - })); - // The slot exists only to host the keymap layer; it renders nothing. - return null; - }, - }); -} - -function boundedError(error: unknown): string { - const message = error instanceof Error ? error.message : String(error); - return message.length > 180 ? `${message.slice(0, 177)}...` : message; -} diff --git a/src/tui/settings/plugin.test.ts b/src/tui/settings/plugin.test.ts new file mode 100644 index 00000000..de6a06d0 --- /dev/null +++ b/src/tui/settings/plugin.test.ts @@ -0,0 +1,195 @@ +// FILE: src/tui/settings/plugin.test.ts +// VERSION: 1.0.0 +// START_MODULE_CONTRACT +// PURPOSE: Tests for the /vv-settings native TUI command: registration shape, gating on an open session, the read path, the write path, and bounded failure toasts. +// SCOPE: Keymap command registration and outcomes through injectable apply/describe seams; no host client is contacted. +// DEPENDS: [bun:test, @opencode/plugin/tui, src/tui/settings/plugin.ts] +// LINKS: [M-PLUGIN-CONTEXT-TUI, V-M-PLUGIN-CONTEXT-TUI] +// ROLE: TEST +// MAP_MODE: LOCALS +// END_MODULE_CONTRACT +// +// START_MODULE_MAP +// CommandShape - Keymap command shape captured from a registered layer. +// createHarness - Fake plugin context recording toasts, route, and the registered command. +// dependencies - Injectable apply/describe seams recording their calls. +// END_MODULE_MAP +// +// START_CHANGE_SUMMARY +// LAST_CHANGE: [C-SESSION-OWNED-SETTINGS T-005 - Covered /vv-settings registration, session gating, show/apply outcomes, and error containment.] +// END_CHANGE_SUMMARY + +import { describe, expect, test } from "bun:test"; +import type { Plugin } from "@opencode/plugin/tui"; +import { + SETTINGS_COMMAND_ID, + registerSettingsTuiCommand, + type SettingsTuiDependencies, +} from "./plugin.js"; + +type CommandShape = { + id?: string; + slash?: { name: string; arguments?: boolean }; + enabled: () => boolean; + run: (input?: string) => Promise | void; +}; + +function createHarness(initialRoute: { type: "session"; sessionID: string } | { type: "home" }) { + const toasts: Array<{ variant?: string; title?: string; message: string }> = []; + let route = initialRoute; + let command: CommandShape | undefined; + let claim: { append?: string; render?: () => unknown } | undefined; + const context = { + ui: { + router: { current: () => route }, + toast: { + show: (toast: { variant?: string; title?: string; message: string }) => toasts.push(toast), + }, + slot: (value: { append?: string; render?: () => unknown }) => { + claim = value; + return () => undefined; + }, + }, + keymap: { + layer: (input: () => { commands?: CommandShape[] }) => { + const layer = input(); + command = layer.commands?.[0]; + }, + }, + } as unknown as Plugin.Context & { client: unknown }; + const mount = () => { + claim?.render?.(); + }; + return { + context, + toasts, + command: () => command, + mount, + setRoute: (next: typeof route) => { + route = next; + }, + }; +} + +function dependencies(overrides: Partial = {}): { + readonly deps: SettingsTuiDependencies; + readonly applied: string[]; + readonly described: string[]; +} { + const applied: string[] = []; + const described: string[] = []; + return { + deps: { + apply: async ({ sessionID, actionText }) => { + applied.push(`${sessionID}:${actionText}`); + return { + rootID: sessionID, + settings: { + version: 1, + roles: { smart: "prov/m2" }, + profile: "orchestrated", + plugins: {}, + }, + }; + }, + describe: async (sessionID) => { + described.push(sessionID); + return "profile: orchestrated"; + }, + ...overrides, + }, + applied, + described, + }; +} + +describe("registerSettingsTuiCommand", () => { + test("registers the slash command on the app slot with arguments enabled", () => { + const harness = createHarness({ type: "session", sessionID: "ses_1" }); + const { deps } = dependencies(); + const dispose = registerSettingsTuiCommand(harness.context, deps); + harness.mount(); + const command = harness.command(); + expect(command?.id).toBe(SETTINGS_COMMAND_ID); + expect(command?.slash?.name).toBe("vv-settings"); + expect(command?.slash?.arguments).toBe(true); + expect(command?.enabled()).toBe(true); + expect(typeof dispose).toBe("function"); + }); + + test("is disabled without an open session and warns when dispatched", async () => { + const harness = createHarness({ type: "home" }); + const { deps, applied } = dependencies(); + registerSettingsTuiCommand(harness.context, deps); + harness.mount(); + expect(harness.command()?.enabled()).toBe(false); + await harness.command()?.run("profile orchestrated"); + expect(applied).toHaveLength(0); + expect(harness.toasts[0]?.variant).toBe("warning"); + }); + + test("with no argument it shows the current settings summary", async () => { + const harness = createHarness({ type: "session", sessionID: "ses_1" }); + const { deps, described, applied } = dependencies(); + registerSettingsTuiCommand(harness.context, deps); + harness.mount(); + await harness.command()?.run(""); + expect(described).toEqual(["ses_1"]); + expect(applied).toHaveLength(0); + expect(harness.toasts[0]?.variant).toBe("info"); + expect(harness.toasts[0]?.message).toContain("profile: orchestrated"); + }); + + test("with no recorded settings it says so", async () => { + const harness = createHarness({ type: "session", sessionID: "ses_1" }); + const { deps } = dependencies({ + describe: async () => undefined, + }); + registerSettingsTuiCommand(harness.context, deps); + harness.mount(); + await harness.command()?.run(undefined); + expect(harness.toasts[0]?.message).toContain("No settings recorded yet"); + }); + + test("an argument applies the action to the active session's family", async () => { + const harness = createHarness({ type: "session", sessionID: "ses_9" }); + const { deps, applied, described } = dependencies(); + registerSettingsTuiCommand(harness.context, deps); + harness.mount(); + await harness.command()?.run("preset vv-zai"); + expect(applied).toEqual(["ses_9:preset vv-zai"]); + expect(described).toHaveLength(0); + expect(harness.toasts[0]?.variant).toBe("success"); + expect(harness.toasts[0]?.message).toContain("profile: orchestrated"); + }); + + test("a failed apply surfaces a bounded error toast and never throws", async () => { + const harness = createHarness({ type: "session", sessionID: "ses_1" }); + const { deps } = dependencies({ + apply: async () => { + throw new Error( + "Family settings for ses_1 are invalid: roles are not a nonempty-string map", + ); + }, + }); + registerSettingsTuiCommand(harness.context, deps); + harness.mount(); + await expect(harness.command()?.run("profile orchestrated")).resolves.toBeUndefined(); + expect(harness.toasts[0]?.variant).toBe("error"); + expect(harness.toasts[0]?.message).toContain("roles are not a nonempty-string map"); + }); + + test("a failed read surfaces an error toast and never throws", async () => { + const harness = createHarness({ type: "session", sessionID: "ses_1" }); + const { deps } = dependencies({ + describe: async () => { + throw new Error("no running OpenCode service was discovered"); + }, + }); + registerSettingsTuiCommand(harness.context, deps); + harness.mount(); + await expect(harness.command()?.run("")).resolves.toBeUndefined(); + expect(harness.toasts[0]?.variant).toBe("error"); + expect(harness.toasts[0]?.message).toContain("no running OpenCode service"); + }); +}); diff --git a/src/tui/settings/plugin.ts b/src/tui/settings/plugin.ts new file mode 100644 index 00000000..520c028c --- /dev/null +++ b/src/tui/settings/plugin.ts @@ -0,0 +1,152 @@ +// FILE: src/tui/settings/plugin.ts +// VERSION: 1.0.0 +// START_MODULE_CONTRACT +// PURPOSE: Register the native OpenCode /vv-settings slash command that changes or shows the current session family's session-owned settings through the running host. +// SCOPE: Keymap command registration through the native app slot, active-session gating, shared text action syntax, one bounded toast per outcome, and lifecycle disposal. All host access goes through the injected session client; no configuration file is read here. +// DEPENDS: [@opencode/plugin/tui, src/commands/session.ts, src/runtime/session-settings.ts, src/tui/context/plugin.ts] +// LINKS: [M-PLUGIN-CONTEXT-TUI, M-NATIVE-RUNTIME, M-CLI-SESSION, V-M-PLUGIN-CONTEXT-TUI] +// ROLE: RUNTIME +// MAP_MODE: EXPORTS +// END_MODULE_CONTRACT +// +// START_MODULE_MAP +// SETTINGS_COMMAND_ID - Stable native keymap command id behind /vv-settings. +// SettingsTuiDependencies - Injectable apply/read seams for focused tests. +// registerSettingsTuiCommand - Register /vv-settings on the native keymap layer. +// END_MODULE_MAP +// +// START_CHANGE_SUMMARY +// LAST_CHANGE: [C-SESSION-OWNED-SETTINGS T-005 - Added /vv-settings as the explicit family-settings command replacing the removed /vv-rebind marker command.] +// END_CHANGE_SUMMARY + +import type { Plugin } from "@opencode/plugin/tui"; +import { + applySessionSettingsAction, + describeFamilySettings, + parseSessionSettingsAction, + readFamilySettingsViaClient, +} from "../../commands/session.js"; +import type { FamilySettings, SessionSettingsClient } from "../../runtime/session-settings.js"; +import { activeSessionID } from "../context/plugin.js"; + +/** Stable native keymap command id behind the /vv-settings slash command. */ +export const SETTINGS_COMMAND_ID = "vvoc.settings.set"; + +/** Injectable apply/read seams for focused tests. */ +export type SettingsTuiDependencies = { + readonly apply: (input: { + readonly sessionID: string; + readonly actionText: string; + }) => Promise<{ readonly rootID: string; readonly settings: FamilySettings }>; + readonly describe: (sessionID: string) => Promise; +}; + +/** + * Register /vv-settings on the native keymap layer, mirroring the /context + * registration: the layer must be created inside a mounted slot render, so the + * command claims the always-present `app` slot. With no argument the command + * shows the current family settings; with the shared text action syntax + * (`preset `, `profile `, `role `, `role unset `, + * `plugin on|off`, `pull-defaults`) it applies exactly one change to the + * active session's family root through a lineage-rooted metadata + * read-modify-write. The command displays bounded summaries only and never + * shows credential values. + */ +export function registerSettingsTuiCommand( + ctx: Plugin.Context & { readonly client: SessionSettingsClient }, + dependencies: SettingsTuiDependencies = { + async apply({ sessionID, actionText }) { + return applySessionSettingsAction({ + client: ctx.client, + sessionID, + action: parseSessionSettingsAction(actionText), + cwd: process.cwd(), + }); + }, + async describe(sessionID) { + const settings = await readFamilySettingsViaClient({ client: ctx.client, sessionID }); + return settings === undefined ? undefined : describeFamilySettings(settings).join("\n"); + }, + }, +): () => void { + return ctx.ui.slot({ + append: "app", + render() { + ctx.keymap.layer(() => ({ + // Global so the command stays reachable from the prompt input mode, + // like /context; the default base layer is not. + mode: "global", + commands: [ + { + id: SETTINGS_COMMAND_ID, + title: "Session settings", + description: + "Show or change this session family's settings (preset, profile, roles, plugins)", + group: "VVOC", + palette: true, + slash: { name: "vv-settings", arguments: true }, + enabled: () => activeSessionID(ctx) !== undefined, + run: async (input?: string) => { + const sessionID = activeSessionID(ctx); + if (sessionID === undefined) { + // Reachable only through the palette when no session is open. + ctx.ui.toast.show({ + variant: "warning", + title: "Session settings", + message: "Open a session before running /vv-settings.", + }); + return; + } + const argument = (input ?? "").trim(); + if (argument.length === 0) { + try { + const summary = await dependencies.describe(sessionID); + if (activeSessionID(ctx) !== sessionID) return; + ctx.ui.toast.show({ + variant: "info", + title: "Session settings", + message: + summary ?? + "No settings recorded yet; the family resolves the current defaults.", + }); + } catch (error) { + if (activeSessionID(ctx) !== sessionID) return; + ctx.ui.toast.show({ + variant: "error", + title: "Session settings unavailable", + message: boundedError(error), + }); + } + return; + } + try { + const result = await dependencies.apply({ sessionID, actionText: argument }); + // A user who switched tabs mid-write did not see the outcome. + if (activeSessionID(ctx) !== sessionID) return; + ctx.ui.toast.show({ + variant: "success", + title: "Session settings updated", + message: `${describeFamilySettings(result.settings).join(" · ")}`, + }); + } catch (error) { + if (activeSessionID(ctx) !== sessionID) return; + ctx.ui.toast.show({ + variant: "error", + title: "Session settings rejected", + message: boundedError(error), + }); + } + }, + }, + ], + })); + // The slot exists only to host the keymap layer; it renders nothing. + return null; + }, + }); +} + +function boundedError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message.length > 180 ? `${message.slice(0, 177)}...` : message; +}