diff --git a/.gitignore b/.gitignore index 9384157ea4..579eb21aab 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ target/ *.node *.node.build.json *.b64.js +# `bun build --compile` leaves these next to the entrypoint when it is interrupted. +*.bun-build # Environment .env diff --git a/AGENTS.md b/AGENTS.md index 0a99615892..7e8f411557 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,16 @@ bun run dev:doctor # verify PATH resolution of `gjc` points at this wor bun run install:defaults # (re)install bundled default definitions ``` +Removing build output (never touches sources, `node_modules/`, `.gjc/` state, or `artifacts/` evidence): + +```sh +bun run clean # dist/, binaries/, coverage/, stray *.bun-build, *.tsbuildinfo +bun run clean:native # also drop compiled .node addons (rebuild via build:native) +bun scripts/clean.ts --dry-run # list targets without deleting +``` + +`clean` removes `packages/coding-agent/dist/`, so a `--binary`-linked `gjc` (see `dev:doctor`) stops resolving until you run `bun run --cwd=packages/coding-agent build` again. Source-linked setups are unaffected. + Verification (never run `tsc`/`npx tsc` directly at repo root; use these): ```sh diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 8c2b229cbb..aff9b3d340 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -64,6 +64,7 @@ These are consumed via `getEnvApiKey()` (`packages/ai/src/stream.ts`) unless not | `OPENROUTER_API_KEY` | OpenRouter auth | Using OpenRouter models | Also used by image tool when preferred/auto provider is OpenRouter | | `MISTRAL_API_KEY` | Mistral auth | Using Mistral models | | | `ZAI_API_KEY` | z.ai auth | Using z.ai models | Also used by z.ai web search provider | +| `JUNIE_API_KEY` | JetBrains AI (Junie) auth | Using `jetbrains-junie` models | Access token from [junie.jetbrains.com/cli](https://junie.jetbrains.com/cli); sent as `Authorization: Bearer` | | `MINIMAX_API_KEY` | MiniMax auth | Using `minimax` provider | | | `AZURE_OPENAI_API_KEY` | Azure OpenAI auth | Using `azure-openai` / `azure-openai-responses` models | Pair with `AZURE_OPENAI_BASE_URL` or `AZURE_OPENAI_RESOURCE_NAME` | | `MINIMAX_CODE_API_KEY` | MiniMax Code auth | Using `minimax-code` provider | | diff --git a/docs/external-control-readiness.md b/docs/external-control-readiness.md index 4829e71dbd..ae395c8c08 100644 --- a/docs/external-control-readiness.md +++ b/docs/external-control-readiness.md @@ -90,6 +90,8 @@ See [Environment Variables](./environment-variables.md#11-acp-permission-handlin GJC's ACP session configuration carries the spec-defined `category` on the Mode, Model, and Thinking select options (`mode`, `model`, `thought_level`), which lets ACP clients such as Paseo discover models and thinking levels without provider-specific metadata. The model catalog is filtered to providers with usable stored credentials (`providers.list/active`), falling back to the full catalog on session hosts that do not expose that query. +Model profiles also appear in the ordinary **Model** picker as synthetic entries under the reserved namespace, e.g. `gajae-code/codex-eco` (displayed with the profile label, such as "Codex Eco"). Selecting one through the ACP `Model` select immediately switches the live session to the full profile without persisting `modelProfile.default`; persistence remains an explicit `/model` TUI choice or `gjc --mpreset codex-eco --default`. Only profiles whose providers have usable stored credentials are selectable; synthetic rows are already availability-filtered by the session host, so the Q29 active-provider filter never drops them. An unavailable-but-active profile stays visible as the current readback and, if selected, fails with the existing authentication-required error. The separate ACP startup `--mpreset`/Q27 `Preset` select is likewise session-scoped and non-persistent. + Sessions launched through an ACP client (e.g. `paseo run --provider gjc/...`) are broker-managed and appear in ACP `session/list`, so Paseo's import flow can attach them. Interactive `gjc` sessions host their own SDK endpoint and are not broker-registered, so they are not listed by ACP clients; use the GJC SDK/notifications surface to control those sessions. ## ACP conformance and Air release gates diff --git a/docs/models.md b/docs/models.md index 93bcd7e4b0..8d23b48b86 100644 --- a/docs/models.md +++ b/docs/models.md @@ -251,6 +251,12 @@ gjc --mpreset opencodego --default ``` The `/model` command opens to a preset landing view: presets are grouped by provider with live auth marks (✓/✗), highlighting a group expands its tiers, and selecting a tier shows the full role→model preview before applying for the session or as default. Typing jumps straight to model search, and `Browse all models` opens the classic tabbed model selector. In `/login`, `Add custom provider` is the first option for configuring credentials needed by custom or profile-required providers; after a successful provider login, the matching preset is recommended automatically. +External SDK/ACP clients (e.g. the Paseo TUI) can select profiles like ordinary +models: the SDK `models.list/current` (Q10) catalog exposes every usable profile +as a synthetic `gajae-code/` entry (e.g. `gajae-code/codex-eco`), and +selecting one through `model.set` (or the ACP Model picker) activates the +profile for the live session only. Persisting a profile remains an explicit TUI +choice, mirroring `gjc --mpreset --default`. See [SDK model profiles](./sdk.md#model-profiles-as-synthetic-models-gajae-codeprofile). MiniMax's OpenAI-compatible endpoint rejects multiple system messages and emits thinking in `reasoning_content`, so pin the public-safe compatibility fields when hand-authoring a custom provider: @@ -283,6 +289,35 @@ providers: models: - id: glm-4.6 ``` + +### JetBrains AI (Junie) + +`jetbrains-junie` is a first-class provider serving JetBrains-hosted models through the documented +Ingrazzio gateway (`https://ingrazzio-cloud-prod.labs.jb.gg`). + +Authenticate with an access token generated at [junie.jetbrains.com/cli](https://junie.jetbrains.com/cli): + +```sh +export JUNIE_API_KEY=... +``` + +The token is sent as `Authorization: Bearer` — JetBrains AI rejects requests that also carry `x-api-key`, so +this provider never lets the Anthropic SDK attach one. Usage is billed against your JetBrains AI +subscription, so bundled per-token costs are zero. There is no OAuth login flow; the environment variable is +the only supported credential source. + +The gateway multiplexes transports by model family: + +| Family | Models | Transport | Prompt limit | +| --- | --- | --- | --- | +| Claude | `claude-sonnet-4-6` (default), `claude-sonnet-5`, `claude-opus-4-6`, `claude-opus-4-7`, `claude-opus-4-8`, `claude-opus-5`, `claude-fable-5` | `anthropic-messages` | 1M | +| GPT | `gpt-5-2025-08-07`, `gpt-5.2-2025-12-11`, `gpt-5.4`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra` | `openai-completions` | 922K | +| GPT (Responses-only) | `gpt-5.3-codex` | `openai-responses` | 272K | + +All models cap output at 128K. Junie also exposes Gemini and Grok, but those ride a proprietary Grazie +translation protocol that GJC does not implement, so they are deliberately not bundled. The bare +`opus`/`sonnet`/`gpt`/`grok` aliases are Junie CLI shorthands the gateway itself rejects. + ### Allowed auth/discovery values - `auth`: `apiKey` (default), `none`, or `oauth`; for `models.yml` custom models, `oauth` is accepted by schema but does not waive the `apiKey` requirement diff --git a/docs/sdk-app-guide.md b/docs/sdk-app-guide.md index 72690c535f..17a1575b73 100644 --- a/docs/sdk-app-guide.md +++ b/docs/sdk-app-guide.md @@ -182,6 +182,13 @@ Beyond frames, the WS surface exposes typed **control operations** `usage.get`, `models.list/current`, `workflow.gates.list`, …). See the [SDK wire protocol & machine interfaces](./sdk.md) for the complete catalog. +The `models.list/current` (Q10) catalog also lists model profiles as synthetic +`gajae-code/` entries (e.g. `gajae-code/codex-eco`). Treat them as +logical selections, not API providers: sending the id back through `model.set` +activates the profile for the live session only. Persisting remains an explicit +TUI choice. Request Q27 (`models.profiles.list`) when you need the +full profile catalog including unavailable profiles and their `available` +status. See [Model profiles as synthetic models](./sdk.md#model-profiles-as-synthetic-models-gajae-codeprofile). ## Creating and supervising sessions diff --git a/docs/sdk.md b/docs/sdk.md index 2398867603..6446541a73 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -283,6 +283,65 @@ as a `model.set` input. Malformed reasoning descriptors are not client-recoverable catalog data. The query returns the SDK's safe `internal` error rather than exposing a partially formed row or descriptor details. +### Model profiles as synthetic models (`gajae-code/`) + +The Q10 catalog also exposes model profiles as logical synthetic models under +the reserved provider namespace `gajae-code`, e.g. `gajae-code/codex-eco`. +These rows let clients (such as ACP model pickers) offer presets like ordinary +models without provider-specific metadata: + +```json +{ + "provider": "gajae-code", + "id": "codex-eco", + "name": "Codex Eco", + "contextWindow": 222222, + "maxTokens": 8888, + "reasoning": false, + "thinking": { "validLevels": ["off"] }, + "current": false +} +``` + +- `gajae-code/` is a **logical namespace, not a callable provider**. No + API transport, credentials, or streaming route is registered for it; send the + value back through the generic `model.set` control (or the ACP `Model` + select) to activate the profile. +- Synthetic rows are **availability-filtered**: only profiles whose required and + alternative providers have usable stored credentials are listed. The profile + id suffix is parsed losslessly after the first namespace slash, so profile ids + containing additional slashes or punctuation round-trip exactly. +- `contextWindow`/`maxTokens` mirror the profile's resolvable default model when + available and otherwise fall back to the shared unknown-model constants + (222222 / 8888); the profile's real default model remains authoritative. +- Synthetic rows are non-reasoning with `validLevels: ["off"]`: a `model.set` + on a synthetic id with any thinking level other than `off` is rejected with + `invalid_input`, and only an absent or `off` level is forwarded as a session + override. +- **Current-state semantics:** while a profile is active for the session, exactly + the synthetic row carries `current: true` with `currentThinkingLevel: + "inherit"`, and the underlying concrete row is not marked current. A persisted + `modelProfile.default` alone (without an in-session active marker) never + creates a synthetic current row. Selecting a concrete `provider/model` clears + the active marker and restores concrete current semantics. +- **Selecting a synthetic profile is session-scoped.** `model.set` with + `gajae-code/` activates the full profile in the live session without + writing `modelProfile.default`, `modelRoles`, or + `task.agentModelOverrides`. Persisting a profile remains an explicit TUI + choice (`/model` → default), mirroring `gjc --mpreset --default`. + Unknown or ambiguous synthetic ids fail with `invalid_input`; missing profile + credentials fail with the existing authentication-required error. +- `gajae-code` is **reserved**: a user-defined `models.yml` provider of the same + name disables the synthetic facade (rows are omitted and synthetic selection + is rejected) rather than being silently shadowed. Q27 (`models.profiles.list`) + remains the full profile catalog with explicit `available` status; Q10 is the + availability-aware facade for client selection. +`config.patch` mutations are serialized through the same session admission +boundary as profile activation and default-model selection, so a patch racing +a synthetic `gajae-code/*` selection (or another patch) is applied in a +deterministic order and is never lost or clobbered by an activation rollback. +The cost is the same as `model.set`: an external `config.patch` queues behind +any in-flight prompt admission rather than applying mid-turn. ## Prompt acceptance, termination, and reconciliation (Q26) diff --git a/package.json b/package.json index 471f7130d3..c96bdf8325 100644 --- a/package.json +++ b/package.json @@ -94,9 +94,11 @@ "stats": "bun --cwd=packages/coding-agent src/cli.ts stats", "build": "bun run --workspaces --if-present build", "build:native": "bun --cwd=packages/natives run build", + "clean": "bun scripts/clean.ts", + "clean:native": "bun scripts/clean.ts --native", "test": "bun run --parallel test:ts test:rs", "test:ts": "bun run test:release && bun run --workspaces --if-present test", - "test:release": "bun test scripts/nightly-release.test.ts scripts/release-evidence.test.ts scripts/release-policy.test.ts scripts/release-publish-order.test.ts scripts/restart-sdk-broker.test.ts", + "test:release": "bun test scripts/clean.test.ts scripts/nightly-release.test.ts scripts/release-evidence.test.ts scripts/release-policy.test.ts scripts/release-publish-order.test.ts scripts/restart-sdk-broker.test.ts", "generate-schemas": "bun scripts/generate-json-schemas.ts", "check:schemas": "bun scripts/generate-json-schemas.ts --check", "check:public-sync": "bun scripts/check-public-version-sync.ts", diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index a72ad93f9d..1ac4eaf685 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - Added opt-in `compat.supportsResponsesSessionAffinity` for OpenAI Responses custom relays. When enabled, supported `openai-responses` models may send `session_id` and `x-client-request-id` affinity headers to a custom endpoint; canonical OpenAI routing remains automatic and known non-OpenAI provider IDs remain excluded. +- Added the `jetbrains-junie` provider, serving JetBrains-hosted models over the documented Ingrazzio gateway `https://ingrazzio-cloud-prod.labs.jb.gg` (#3626). Auth is the officially documented `JUNIE_API_KEY` access token only — no OAuth login flow and no reverse-engineered client credentials. JetBrains AI rejects requests carrying `x-api-key`, so the provider passes `apiKey: null` to the Anthropic SDK and relies solely on the `Authorization: Bearer` header that `buildAnthropicHeaders` already emits for non-Anthropic hosts. The gateway multiplexes transports by family via the `X-LLM-Model` routing header: 7 Claude models on `anthropic-messages` (1M prompt window), 7 GPT models on `openai-completions` and `gpt-5.3-codex` on `openai-responses` (922K and 272K respectively); all cap output at 128K. The GPT lane pins a `/v1`-suffixed base URL because the OpenAI transports append a bare route while the Anthropic one supplies its own prefix. Ids come from Junie CLI's own catalog cross-checked against the 2470.4 jar; Gemini and Grok are excluded because their Grazie translation protocol is not implemented, and the bare `opus`/`sonnet` aliases are CLI shorthands the gateway rejects. Limits are the gateway's probed ceilings, not Junie CLI's smaller per-request budgets. ### Fixed diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index b77b313eb4..13ca910aa4 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -159,6 +159,140 @@ export function injectAlibabaTokenPlanModels(models: Model[]): void { } } +/** + * JetBrains AI (Junie) is not published on models.dev and exposes no model-list + * endpoint, so its catalog is declared statically. + * + * The ids are the CLI's own authoritative list (`junie --model ` prints + * it), cross-checked against the model constants compiled into the Junie CLI + * 2470.4 jar. The CLI additionally offers bare `opus`/`sonnet`/`gpt`/`grok` + * aliases, but those are client-side shorthands the gateway rejects with + * `Model not found for tag`, so they are deliberately excluded. + * + * The gateway multiplexes transports by family, selected with the `X-LLM-Model` + * routing header, all captured from live traffic: + * - Claude -> `X-LLM-Model: anthropic`, Anthropic Messages on `/v1/messages` + * - GPT -> `X-LLM-Model: openai`, Chat Completions on `/v1/chat/completions`, + * except `gpt-5.3-codex` which is Responses-only (Chat Completions + * rejects it with `OpenAI Completions Proxy API is not supported`) + * - Gemini -> `X-LLM-Model: google`, proprietary Grazie translation protocol + * - Grok -> `X-LLM-Model: grok` + * + * Claude and GPT are verified end to end against the live gateway. Gemini and + * Grok are listed from the same authoritative source but their transports are + * not implemented here, so they are intentionally NOT bundled — shipping a + * catalog entry GJC cannot dispatch would fail at request time instead of being + * absent from `/model`. + * + * `contextWindow` and `maxTokens` are the gateway's enforced ceilings, probed + * directly rather than copied from Junie CLI's request values (the CLI sends much + * smaller per-model budgets, which are its own policy, not the endpoint limit). + * Claude rejects with `prompt is too long: N tokens > 1000000 maximum`; GPT with + * `Input tokens exceed the configured limit of 922000 tokens`. Both families cap + * output at 128000. + */ +export const JETBRAINS_JUNIE_BASE_URL = "https://ingrazzio-cloud-prod.labs.jb.gg"; + +/** + * The OpenAI transports append a bare `/chat/completions` (or `/responses`) to + * `baseUrl`, whereas the Anthropic transport supplies its own `/v1` prefix. The + * gateway only serves the `/v1`-prefixed routes, so the GPT lane pins it here. + */ +const JETBRAINS_JUNIE_OPENAI_BASE_URL = `${JETBRAINS_JUNIE_BASE_URL}/v1`; + +const JETBRAINS_JUNIE_ANTHROPIC_HEADERS: Record = { + "X-LLM-Model": "anthropic", + "X-Keep-Path": "true", +}; + +const JETBRAINS_JUNIE_OPENAI_HEADERS: Record = { + "X-LLM-Model": "openai", + "X-Keep-Path": "true", +}; + +/** Gateway-enforced output ceiling, probed against the live endpoint. */ +const JETBRAINS_JUNIE_MAX_TOKENS = 128_000; +/** Gateway-enforced prompt ceiling for the Claude lane, probed live. */ +const JETBRAINS_JUNIE_ANTHROPIC_CONTEXT_WINDOW = 1_000_000; +/** Gateway-enforced prompt ceiling for the GPT lane, probed live. */ +const JETBRAINS_JUNIE_OPENAI_CONTEXT_WINDOW = 922_000; + +export function injectJetBrainsJunieModels(models: Model[]): void { + const claudeModels: Model<"anthropic-messages">[] = [ + { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (Junie)" }, + { id: "claude-sonnet-5", name: "Claude Sonnet 5 (Junie)" }, + { id: "claude-opus-4-6", name: "Claude Opus 4.6 (Junie)" }, + { id: "claude-opus-4-7", name: "Claude Opus 4.7 (Junie)" }, + { id: "claude-opus-4-8", name: "Claude Opus 4.8 (Junie)" }, + { id: "claude-opus-5", name: "Claude Opus 5 (Junie)" }, + { id: "claude-fable-5", name: "Claude Fable 5 (Junie)" }, + ].map(({ id, name }) => ({ + id, + name, + api: "anthropic-messages", + provider: "jetbrains-junie", + baseUrl: JETBRAINS_JUNIE_BASE_URL, + reasoning: true, + input: ["text", "image"], + // JetBrains bills these through a JetBrains AI subscription, not per token. + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: JETBRAINS_JUNIE_ANTHROPIC_CONTEXT_WINDOW, + maxTokens: JETBRAINS_JUNIE_MAX_TOKENS, + // `applyGeneratedModelPolicies` derives the adaptive thinking config from the model id. + headers: JETBRAINS_JUNIE_ANTHROPIC_HEADERS, + })); + + const gptCompletionsModels: Model<"openai-completions">[] = [ + { id: "gpt-5-2025-08-07", name: "GPT-5 (Junie)" }, + { id: "gpt-5.2-2025-12-11", name: "GPT-5.2 (Junie)" }, + { id: "gpt-5.4", name: "GPT-5.4 (Junie)" }, + { id: "gpt-5.5", name: "GPT-5.5 (Junie)" }, + { id: "gpt-5.6-luna", name: "GPT-5.6 Luna (Junie)" }, + { id: "gpt-5.6-sol", name: "GPT-5.6 Sol (Junie)" }, + { id: "gpt-5.6-terra", name: "GPT-5.6 Terra (Junie)" }, + ].map(({ id, name }) => ({ + id, + name, + api: "openai-completions", + provider: "jetbrains-junie", + baseUrl: JETBRAINS_JUNIE_OPENAI_BASE_URL, + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: JETBRAINS_JUNIE_OPENAI_CONTEXT_WINDOW, + maxTokens: JETBRAINS_JUNIE_MAX_TOKENS, + headers: JETBRAINS_JUNIE_OPENAI_HEADERS, + })); + + // Responses-only: the Chat Completions route rejects this id outright. + const gptResponsesModels: Model<"openai-responses">[] = [{ id: "gpt-5.3-codex", name: "GPT-5.3 Codex (Junie)" }].map( + ({ id, name }) => ({ + id, + name, + api: "openai-responses", + provider: "jetbrains-junie", + baseUrl: JETBRAINS_JUNIE_OPENAI_BASE_URL, + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: JETBRAINS_JUNIE_OPENAI_CONTEXT_WINDOW, + maxTokens: JETBRAINS_JUNIE_MAX_TOKENS, + headers: JETBRAINS_JUNIE_OPENAI_HEADERS, + }), + ); + + const junieModels: Model[] = [...claudeModels, ...gptCompletionsModels, ...gptResponsesModels]; + + for (const metadata of junieModels) { + const existing = models.find(model => model.provider === "jetbrains-junie" && model.id === metadata.id); + if (existing) { + Object.assign(existing, metadata); + } else { + models.push(metadata); + } + } +} + async function resolveProviderApiKey(providerId: string, catalog: CatalogDiscoveryConfig): Promise { for (const envVar of catalog.envVars) { const value = $env[envVar as keyof typeof $env]; @@ -546,6 +680,7 @@ async function generateModels() { allModels = applyCodexPricingFallback(allModels); allModels = applyClaudeOpusVisionCorrections(allModels); injectAlibabaTokenPlanModels(allModels); + injectJetBrainsJunieModels(allModels); applyGeneratedModelPolicies(allModels); linkOpenAIPromotionTargets(allModels); injectImageGenerationModels(allModels); diff --git a/packages/ai/src/model-thinking.ts b/packages/ai/src/model-thinking.ts index ff1ce5336d..f4e33a18b0 100644 --- a/packages/ai/src/model-thinking.ts +++ b/packages/ai/src/model-thinking.ts @@ -523,6 +523,12 @@ function inferGeneratedApplyPatchToolType( function applyGpt55ContextWindow(model: ApiModel, parsedModel: OpenAIModel): boolean { if (parsedModel.variant === "base" && semverEqual(parsedModel.version, "5.5")) { + // JetBrains AI serves GPT through its own gateway, which enforces a probed + // 922K prompt cap for every GPT model regardless of the first-party figure. + // Its bundled value is measured, so leave it alone. + if (model.provider === "jetbrains-junie") { + return true; + } // The first-party OpenAI GPT-5.5 model advertises a 1M total window, but // the OpenAI code backend request path still enforces the smaller prompt // budget. GJC's `contextWindow` is the usable prompt/input cap, not the diff --git a/packages/ai/src/models.json b/packages/ai/src/models.json index f56b063e92..5d9f17dd1f 100644 --- a/packages/ai/src/models.json +++ b/packages/ai/src/models.json @@ -13299,6 +13299,450 @@ } } }, + "jetbrains-junie": { + "claude-fable-5": { + "id": "claude-fable-5", + "name": "Anthropic Fable 5 (Junie)", + "api": "anthropic-messages", + "provider": "jetbrains-junie", + "baseUrl": "https://ingrazzio-cloud-prod.labs.jb.gg", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 1000000, + "maxTokens": 128000, + "headers": { + "X-LLM-Model": "anthropic", + "X-Keep-Path": "true" + }, + "thinking": { + "mode": "anthropic-adaptive", + "minLevel": "minimal", + "maxLevel": "xhigh" + } + }, + "claude-opus-4-6": { + "id": "claude-opus-4-6", + "name": "Anthropic Opus 4.6 (Junie)", + "api": "anthropic-messages", + "provider": "jetbrains-junie", + "baseUrl": "https://ingrazzio-cloud-prod.labs.jb.gg", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 1000000, + "maxTokens": 128000, + "headers": { + "X-LLM-Model": "anthropic", + "X-Keep-Path": "true" + }, + "thinking": { + "mode": "anthropic-adaptive", + "minLevel": "minimal", + "maxLevel": "max", + "levels": [ + "minimal", + "low", + "medium", + "high", + "max" + ] + } + }, + "claude-opus-4-7": { + "id": "claude-opus-4-7", + "name": "Anthropic Opus 4.7 (Junie)", + "api": "anthropic-messages", + "provider": "jetbrains-junie", + "baseUrl": "https://ingrazzio-cloud-prod.labs.jb.gg", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 1000000, + "maxTokens": 128000, + "headers": { + "X-LLM-Model": "anthropic", + "X-Keep-Path": "true" + }, + "thinking": { + "mode": "anthropic-adaptive", + "minLevel": "minimal", + "maxLevel": "max" + } + }, + "claude-opus-4-8": { + "id": "claude-opus-4-8", + "name": "Anthropic Opus 4.8 (Junie)", + "api": "anthropic-messages", + "provider": "jetbrains-junie", + "baseUrl": "https://ingrazzio-cloud-prod.labs.jb.gg", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 1000000, + "maxTokens": 128000, + "headers": { + "X-LLM-Model": "anthropic", + "X-Keep-Path": "true" + }, + "thinking": { + "mode": "anthropic-adaptive", + "minLevel": "minimal", + "maxLevel": "max" + } + }, + "claude-opus-5": { + "id": "claude-opus-5", + "name": "Anthropic Opus 5 (Junie)", + "api": "anthropic-messages", + "provider": "jetbrains-junie", + "baseUrl": "https://ingrazzio-cloud-prod.labs.jb.gg", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 1000000, + "maxTokens": 128000, + "headers": { + "X-LLM-Model": "anthropic", + "X-Keep-Path": "true" + }, + "thinking": { + "mode": "anthropic-adaptive", + "minLevel": "minimal", + "maxLevel": "max" + } + }, + "claude-sonnet-4-6": { + "id": "claude-sonnet-4-6", + "name": "Anthropic Sonnet 4.6 (Junie)", + "api": "anthropic-messages", + "provider": "jetbrains-junie", + "baseUrl": "https://ingrazzio-cloud-prod.labs.jb.gg", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 1000000, + "maxTokens": 128000, + "headers": { + "X-LLM-Model": "anthropic", + "X-Keep-Path": "true" + }, + "thinking": { + "mode": "anthropic-adaptive", + "minLevel": "minimal", + "maxLevel": "high" + } + }, + "claude-sonnet-5": { + "id": "claude-sonnet-5", + "name": "Anthropic Sonnet 5 (Junie)", + "api": "anthropic-messages", + "provider": "jetbrains-junie", + "baseUrl": "https://ingrazzio-cloud-prod.labs.jb.gg", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 1000000, + "maxTokens": 128000, + "headers": { + "X-LLM-Model": "anthropic", + "X-Keep-Path": "true" + }, + "thinking": { + "mode": "anthropic-adaptive", + "minLevel": "minimal", + "maxLevel": "max" + } + }, + "gpt-5-2025-08-07": { + "id": "gpt-5-2025-08-07", + "name": "GPT-5 (Junie)", + "api": "openai-completions", + "provider": "jetbrains-junie", + "baseUrl": "https://ingrazzio-cloud-prod.labs.jb.gg/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 922000, + "maxTokens": 128000, + "headers": { + "X-LLM-Model": "openai", + "X-Keep-Path": "true" + }, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "xhigh" + } + }, + "gpt-5.2-2025-12-11": { + "id": "gpt-5.2-2025-12-11", + "name": "GPT-5.2 (Junie)", + "api": "openai-completions", + "provider": "jetbrains-junie", + "baseUrl": "https://ingrazzio-cloud-prod.labs.jb.gg/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 922000, + "maxTokens": 128000, + "headers": { + "X-LLM-Model": "openai", + "X-Keep-Path": "true" + }, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "xhigh" + } + }, + "gpt-5.3-codex": { + "id": "gpt-5.3-codex", + "name": "GPT-5.3 OpenAI code (Junie)", + "api": "openai-responses", + "provider": "jetbrains-junie", + "baseUrl": "https://ingrazzio-cloud-prod.labs.jb.gg/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 272000, + "maxTokens": 128000, + "headers": { + "X-LLM-Model": "openai", + "X-Keep-Path": "true" + }, + "thinking": { + "mode": "effort", + "minLevel": "low", + "maxLevel": "xhigh" + } + }, + "gpt-5.4": { + "id": "gpt-5.4", + "name": "GPT-5.4 (Junie)", + "api": "openai-completions", + "provider": "jetbrains-junie", + "baseUrl": "https://ingrazzio-cloud-prod.labs.jb.gg/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 922000, + "maxTokens": 128000, + "headers": { + "X-LLM-Model": "openai", + "X-Keep-Path": "true" + }, + "thinking": { + "mode": "effort", + "minLevel": "low", + "maxLevel": "xhigh" + } + }, + "gpt-5.5": { + "id": "gpt-5.5", + "name": "GPT-5.5 (Junie)", + "api": "openai-completions", + "provider": "jetbrains-junie", + "baseUrl": "https://ingrazzio-cloud-prod.labs.jb.gg/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 922000, + "maxTokens": 128000, + "headers": { + "X-LLM-Model": "openai", + "X-Keep-Path": "true" + }, + "thinking": { + "mode": "effort", + "minLevel": "low", + "maxLevel": "xhigh" + } + }, + "gpt-5.6-luna": { + "id": "gpt-5.6-luna", + "name": "GPT-5.6 Luna (Junie)", + "api": "openai-completions", + "provider": "jetbrains-junie", + "baseUrl": "https://ingrazzio-cloud-prod.labs.jb.gg/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 922000, + "maxTokens": 128000, + "headers": { + "X-LLM-Model": "openai", + "X-Keep-Path": "true" + }, + "thinking": { + "mode": "effort", + "minLevel": "low", + "maxLevel": "max" + } + }, + "gpt-5.6-sol": { + "id": "gpt-5.6-sol", + "name": "GPT-5.6 Sol (Junie)", + "api": "openai-completions", + "provider": "jetbrains-junie", + "baseUrl": "https://ingrazzio-cloud-prod.labs.jb.gg/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 922000, + "maxTokens": 128000, + "headers": { + "X-LLM-Model": "openai", + "X-Keep-Path": "true" + }, + "thinking": { + "mode": "effort", + "minLevel": "low", + "maxLevel": "max" + } + }, + "gpt-5.6-terra": { + "id": "gpt-5.6-terra", + "name": "GPT-5.6 Terra (Junie)", + "api": "openai-completions", + "provider": "jetbrains-junie", + "baseUrl": "https://ingrazzio-cloud-prod.labs.jb.gg/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 922000, + "maxTokens": 128000, + "headers": { + "X-LLM-Model": "openai", + "X-Keep-Path": "true" + }, + "thinking": { + "mode": "effort", + "minLevel": "low", + "maxLevel": "max" + } + } + }, "kilo": { "~anthropic/claude-fable-latest": { "id": "~anthropic/claude-fable-latest", diff --git a/packages/ai/src/provider-models/descriptors.ts b/packages/ai/src/provider-models/descriptors.ts index a78107a060..748b00f893 100644 --- a/packages/ai/src/provider-models/descriptors.ts +++ b/packages/ai/src/provider-models/descriptors.ts @@ -51,6 +51,7 @@ import { import { cursorModelManagerOptions, glmZcodeModelManagerOptions, + jetbrainsJunieModelManagerOptions, openCodexModelManagerOptions, zaiModelManagerOptions, } from "./special"; @@ -346,6 +347,7 @@ export const PROVIDER_DESCRIPTORS: readonly ProviderDescriptor[] = [ config => glmZcodeModelManagerOptions(config), catalog("GLM ZCode (unofficial)", ["GLM_ZCODE_API_KEY"], { oauthProvider: "glm-zcode" }), ), + descriptor("jetbrains-junie", "claude-sonnet-4-6", config => jetbrainsJunieModelManagerOptions(config)), descriptor("github-copilot", "gpt-4o", config => githubCopilotModelManagerOptions(config)), descriptor("google", "gemini-2.5-pro", config => googleModelManagerOptions(config)), catalogDescriptor( diff --git a/packages/ai/src/provider-models/special.ts b/packages/ai/src/provider-models/special.ts index b7e63a4c84..17c467bc54 100644 --- a/packages/ai/src/provider-models/special.ts +++ b/packages/ai/src/provider-models/special.ts @@ -85,3 +85,14 @@ export function glmZcodeModelManagerOptions( ): ModelManagerOptions<"anthropic-messages"> { return { providerId: "glm-zcode" }; } +// --------------------------------------------------------------------------- +// JetBrains Junie (JetBrains AI Service, Ingrazzio gateway) +// --------------------------------------------------------------------------- + +export interface JetBrainsJunieModelManagerConfig {} + +export function jetbrainsJunieModelManagerOptions( + _config: JetBrainsJunieModelManagerConfig = {}, +): ModelManagerOptions<"anthropic-messages"> { + return { providerId: "jetbrains-junie" }; +} diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 9e400ada3f..f36330e0a4 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -2228,6 +2228,25 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A }; } + // JetBrains AI (Ingrazzio) authenticates with a plain `Authorization: Bearer` + // token and rejects requests that also carry `X-Api-Key`. `buildAnthropicHeaders` + // already emits the bearer for non-Anthropic hosts, so keep the SDK from adding + // its own API-key header on top of it. + if (model.provider === "jetbrains-junie") { + return { + isOAuthToken: false, + apiKey: null, + authToken: null, + baseURL: baseUrl, + maxRetries: resolveRetryBudget(args.requestMaxRetries, 5), + dangerouslyAllowBrowser: true, + defaultHeaders, + logLevel: ANTHROPIC_SDK_LOG_LEVEL, + fetch: debugFetch, + ...(tlsFetchOptions ? { fetchOptions: tlsFetchOptions } : {}), + }; + } + return { isOAuthToken: oauthToken, apiKey: oauthToken ? null : apiKey, diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index 620782cd52..34d6c7ed96 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -99,6 +99,7 @@ const serviceProviderMap: Record = { "vercel-ai-gateway": "AI_GATEWAY_API_KEY", zai: "ZAI_API_KEY", "glm-zcode": "GLM_ZCODE_API_KEY", + "jetbrains-junie": "JUNIE_API_KEY", mistral: "MISTRAL_API_KEY", minimax: "MINIMAX_API_KEY", "minimax-code": "MINIMAX_CODE_API_KEY", @@ -223,6 +224,11 @@ export function formatProviderCredentialHint(provider: string): string { "OpenCode subscriptions authenticate with an API key (created at https://opencode.ai/auth), not a separate session/OAuth token.", ); } + if (provider === "jetbrains-junie") { + parts.push( + "JetBrains AI (Junie) authenticates with an access token generated at https://junie.jetbrains.com/cli; there is no OAuth login for this provider.", + ); + } if (envVar) { parts.push( `Headless GJC reads this provider's key from ${envVar} (exported in your shell or set in ~/.gjc/.env).`, diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 43c151e2c5..fdd2682523 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -134,6 +134,7 @@ export const KNOWN_PROVIDERS = [ "fugu", "gitlab-duo", "cursor", + "jetbrains-junie", "deepseek", "deepinfra", "xai", diff --git a/packages/ai/test/jetbrains-junie-provider.test.ts b/packages/ai/test/jetbrains-junie-provider.test.ts new file mode 100644 index 0000000000..4caaef9550 --- /dev/null +++ b/packages/ai/test/jetbrains-junie-provider.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from "bun:test"; + +import { getBundledModel, getBundledModels } from "../src/models"; +import { DEFAULT_MODEL_PER_PROVIDER, PROVIDER_DESCRIPTORS } from "../src/provider-models/descriptors"; +import { buildAnthropicClientOptions, buildAnthropicHeaders } from "../src/providers/anthropic"; +import { complete, formatMissingApiKeyError, getEnvApiKey } from "../src/stream"; +import { KNOWN_PROVIDERS } from "../src/types"; +import { getOAuthProviders } from "../src/utils/oauth"; +import { withEnv } from "./helpers"; + +const JUNIE_BASE_URL = "https://ingrazzio-cloud-prod.labs.jb.gg"; +const API_KEY = "junie-test-token"; + +describe("JetBrains Junie provider", () => { + it("is a known provider with claude-sonnet-4-6 as its default model", () => { + expect(KNOWN_PROVIDERS).toContain("jetbrains-junie"); + expect(PROVIDER_DESCRIPTORS.some(d => d.providerId === "jetbrains-junie")).toBe(true); + expect(DEFAULT_MODEL_PER_PROVIDER["jetbrains-junie"]).toBe("claude-sonnet-4-6"); + }); + + it("resolves credentials from JUNIE_API_KEY only", () => { + withEnv({ JUNIE_API_KEY: API_KEY }, () => { + expect(getEnvApiKey("jetbrains-junie")).toBe(API_KEY); + }); + withEnv({ JUNIE_API_KEY: undefined }, () => { + expect(getEnvApiKey("jetbrains-junie")).toBeUndefined(); + }); + }); + + it("bundles the Claude lane on the Anthropic Messages transport", () => { + const claude = getBundledModels("jetbrains-junie").filter(m => m.id.startsWith("claude-")); + expect(claude.map(m => m.id).sort()).toEqual([ + "claude-fable-5", + "claude-opus-4-6", + "claude-opus-4-7", + "claude-opus-4-8", + "claude-opus-5", + "claude-sonnet-4-6", + "claude-sonnet-5", + ]); + for (const model of claude) { + expect(model.api).toBe("anthropic-messages"); + // The Anthropic transport supplies its own /v1 prefix. + expect(model.baseUrl).toBe(JUNIE_BASE_URL); + expect(model.headers?.["X-LLM-Model"]).toBe("anthropic"); + expect(model.headers?.["X-Keep-Path"]).toBe("true"); + // Gateway-enforced ceilings, probed live. Junie CLI sends far smaller + // per-model budgets (20k-60k), but those are its own policy, not the + // endpoint limit -- do not copy them back in. + expect(model.contextWindow).toBe(1_000_000); + expect(model.maxTokens).toBe(128_000); + } + }); + + it("bundles the GPT lane with the /v1-prefixed base URL the OpenAI transports need", () => { + const gpt = getBundledModels("jetbrains-junie").filter(m => m.id.startsWith("gpt-")); + expect(gpt.map(m => m.id).sort()).toEqual([ + "gpt-5-2025-08-07", + "gpt-5.2-2025-12-11", + "gpt-5.3-codex", + "gpt-5.4", + "gpt-5.5", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra", + ]); + for (const model of gpt) { + // The OpenAI transports append a bare /chat/completions or /responses, + // so without this suffix every GPT request 404s on the gateway. + expect(model.baseUrl).toBe(`${JUNIE_BASE_URL}/v1`); + expect(model.headers?.["X-LLM-Model"]).toBe("openai"); + expect(model.maxTokens).toBe(128_000); + } + // gpt-5.3-codex is Responses-only; Chat Completions rejects it outright. + const byId = new Map(gpt.map(m => [m.id, m])); + expect(byId.get("gpt-5.3-codex")?.api).toBe("openai-responses"); + expect(byId.get("gpt-5.6-sol")?.api).toBe("openai-completions"); + // GPT is capped lower than Claude, and a generic GPT-5.5 policy must not + // raise it back to 1M for this provider. + expect(byId.get("gpt-5.5")?.contextWindow).toBe(922_000); + expect(byId.get("gpt-5.6-sol")?.contextWindow).toBe(922_000); + }); + + it("excludes the CLI-only aliases the gateway rejects", () => { + const ids = new Set(getBundledModels("jetbrains-junie").map(m => m.id)); + for (const alias of ["opus", "sonnet", "gpt", "grok"]) { + expect(ids.has(alias)).toBe(false); + } + }); + + it("sends only Authorization: Bearer, never X-Api-Key", () => { + const headers = buildAnthropicHeaders({ apiKey: API_KEY, baseUrl: JUNIE_BASE_URL }); + expect(headers.Authorization).toBe(`Bearer ${API_KEY}`); + expect(headers["X-Api-Key"]).toBeUndefined(); + }); + + it("blocks the SDK from appending its own X-Api-Key header", () => { + const model = getBundledModel("jetbrains-junie", "claude-sonnet-4-6") as Parameters< + typeof buildAnthropicClientOptions + >[0]["model"]; + const resolved = buildAnthropicClientOptions({ model, apiKey: API_KEY }); + + // The SDK adds `X-Api-Key` whenever `apiKey` is set; JetBrains AI rejects that. + expect(resolved.apiKey).toBeNull(); + expect(resolved.authToken).toBeNull(); + expect(resolved.isOAuthToken).toBe(false); + expect(resolved.baseURL).toBe(JUNIE_BASE_URL); + expect(resolved.defaultHeaders?.Authorization).toBe(`Bearer ${API_KEY}`); + expect(resolved.defaultHeaders?.["X-LLM-Model"]).toBe("anthropic"); + }); + + it("drives the request from JUNIE_API_KEY alone, with no explicit apiKey argument", async () => { + const realFetch = globalThis.fetch; + let requestUrl = ""; + let authorization = ""; + let hasApiKeyHeader = true; + + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + requestUrl = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const headers = new Headers(init?.headers); + authorization = headers.get("authorization") ?? ""; + hasApiKeyHeader = headers.has("x-api-key"); + // Short-circuit: the assertion target is the outbound request, not the reply. + return new Response(JSON.stringify({ type: "error", error: { type: "halted" } }), { status: 418 }); + }) as typeof globalThis.fetch; + + try { + await withEnv({ JUNIE_API_KEY: API_KEY }, async () => { + const model = getBundledModel("jetbrains-junie", "claude-sonnet-4-6"); + await complete( + model, + { messages: [{ role: "user", content: "x", timestamp: Date.now() }] }, + { maxTokens: 8 }, + ).catch(() => undefined); + }); + } finally { + globalThis.fetch = realFetch; + } + + expect(requestUrl).toBe(`${JUNIE_BASE_URL}/v1/messages`); + expect(authorization).toBe(`Bearer ${API_KEY}`); + expect(hasApiKeyHeader).toBe(false); + }); + + it("tells the user where to get a key, and does not offer a login flow", () => { + const message = formatMissingApiKeyError("jetbrains-junie"); + expect(message).toContain("JUNIE_API_KEY"); + expect(message).toContain("https://junie.jetbrains.com/cli"); + // There is no OAuth for this provider; suggesting /login would dead-end the user. + expect(message).not.toContain("/login"); + }); + + it("exposes no OAuth login surface", () => { + expect(getOAuthProviders().some(p => p.id === "jetbrains-junie")).toBe(false); + }); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5a73493f64..77c50a567e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,19 +1,29 @@ # Changelog ## [Unreleased] + +### Added + +- `gjc gc` now reports managed session scope capacity when a scope is at or past 75% of the managed byte budget. A scope is snapshotted in full on every session start and fails closed once it exceeds the budget, but it is filled by GJC's own session records, so a working directory in sustained use can cross the limit with no prior signal — the first symptom is a launch that aborts. The probe is read-only and never fails a gc run: an absent, unreadable, or non-directory scope is reported as `unavailable`, an unreadable subtree is skipped so a partial walk still answers "am I near the budget?", and scopes below the threshold are omitted entirely so existing output is unchanged. `gc` still reclaims nothing here; the report names the scope path so stale session directories can be moved out by hand. + ### Fixed - `todo_write` and `ask` no longer reject valid calls before the tool loads. Both tools carried two independent copies of their raw-argument rules — one in the loaded tool, one in the cold descriptor registry that runs first — and the deferred copies had drifted: `todo_write`'s dropped the `content` synonym for `task` and the `complete`/`completed` aliases for `done`, accepted targetless `complete` entries the loaded tool rejects, and returned every rejection without its correction code, so the model saw a bare "raw arguments rejected before coercion" with nothing to fix and retried the same shape until the turn died. Both also rejected the harness's own injected `_i` intent field, failing any call carrying it with an unknown-root-key error the model could not repair. `todo_write` validation now lives in a single shared contract module (`tools/todo-contract.ts`) used by both paths, and both tools tolerate `_i` at the root while still rejecting genuinely unknown keys. - Managed scope prepare and legacy-local resume no longer report success while group/other-readable descendants remain on disk (e.g. mode `0o036`/`0o644`): prepare uses a mode-only walk (not `snapshotManagedTree("")`, which races concurrent writers and broke `/move`) to detect drift, re-secures with apply+verify, and retries once; legacy-local capture uses the same resecure helper. - ACP `session/request_permission` responses are now normalized from the spec-shaped `RequestPermissionResponse` (`{ outcome: { outcome, optionId } }`) into the SDK's flat permission-decision contract before reaching the permission provider. Standards-compliant ACP clients such as Paseo can now authorize permission-gated shell/eval and destructive file operations (`bash`, `monitor`, `eval`, `delete`, `move`, and `edit` only for delete/move operations) without an invalid-response failure; `write` and ordinary edits remain ungated. Nested and flat selected/cancelled responses are accepted by reconstructing the canonical SDK decision fields, while malformed or unknown decisions fail closed. - `bun run restart:sdk-broker --close-session-hosts` no longer aborts the whole restart when one session host refuses to close. A host whose endpoint is unreachable and whose durable process identity cannot be verified — an orphan left behind by an earlier broker — made the broker reply `close_refused`, which surfaced as an uncaught `SdkClientError`, so the broker was never replaced and kept serving the source it started with: precisely the stale-code failure the flag exists to prevent, and worse than the broker-only restart because part of the teardown had already run. Every listed host is now attempted, the replacement broker starts regardless, and surviving hosts are named with their failure reason on stderr with a non-zero exit so a partial teardown is never reported as a clean one. +- An ACP turn can no longer outlive its own end and wedge the session. A prompt whose terminal frame carried no normalized outcome — what an SDK-side `terminal_uncertain` closure produces when agent-owned async work (subagents, IRC deliveries) outlives the turn — was rejected without ever publishing the matching `session_info_update`, so the client stayed in the `working` phase forever. `session/cancel` had the same open end: `turn.abort` was acknowledged but nothing settled the pending `session/prompt` when the aborted run never published a terminal, leaving the waiter pending, the composer spinning, and every later prompt refused with `conflict` (surfacing in Paseo as `cancelAgentRun: acknowledged turn still active after timeout` followed by a permanent `A foreground turn is already active`). Every prompt rejection now releases the running phase, and an acknowledged cancel settles as ACP's mandated `cancelled` stop reason once a bounded grace expires, with a real terminal still winning inside that grace. +- A 61MB `bun build --compile` intermediate (`packages/coding-agent/.18c95f9fdbe9bff8-00000000.bun-build`) had been committed to `dev`, and five more sat untracked in the worktree. `bun build --compile` writes these hidden temporaries next to the entrypoint and leaves them behind when interrupted, and no ignore rule covered them. `*.bun-build` is now ignored and the committed copy is untracked. ### Added +- The SDK `models.list/current` (Q10) catalog now lists model profiles as synthetic `gajae-code/` entries (e.g. `gajae-code/codex-eco`), and selecting one through `model.set` (or the ACP Model picker) activates the profile for the live session only; global persistence remains the explicit `gjc --mpreset --default` or TUI default-selection path. ACP/SDK clients such as Paseo can therefore offer presets like ordinary models; only availability-filtered profiles are advertised, the reserved `gajae-code` namespace fails closed on collision, and `config.patch` serializes with profile activation through the session admission boundary. - Added first-class `cline-pass` and `commandcode-goat` provider presets with documented API endpoints, environment-variable credentials, non-hardcoded live model discovery from models.dev and the Command Code Provider API, and prefix-based Claude routing. +- Registered the `jetbrains-junie` provider in the famous-provider ordering and the `JUNIE_API_KEY` credential help so JetBrains AI (Junie) Claude models surface in `/model`, `--list-models` and `gjc --help` alongside the other first-class providers (#3626). - Added lease-backed MCP connection pooling with typed recovery, shared HTTP/SSE sessions, per-lease callback demultiplexing, and authorization binding scopes that keep credential secrets out of pool keys. - Added plugin registry v2 as the single execution authority for plugin tools, subskills, and prompt appendices, with digest verification at final use. - Added module-trace and process-tree RSS verification harnesses for startup-memory regressions. +- Added `bun run clean` / `bun run clean:native` (`scripts/clean.ts`) to remove build output — `dist/`, `binaries/`, `coverage/`, stray `*.bun-build`, `*.tsbuildinfo`, and with `--native` compiled `.node` addons. Sources, `node_modules/`, `.gjc/` runtime state, and `artifacts/` test evidence are refused as targets rather than silently skipped, and `--dry-run` lists targets without deleting. ### Changed diff --git a/packages/coding-agent/scripts/generate-sdk-operation-inventory.ts b/packages/coding-agent/scripts/generate-sdk-operation-inventory.ts index 44e615ba36..5b318a05cf 100644 --- a/packages/coding-agent/scripts/generate-sdk-operation-inventory.ts +++ b/packages/coding-agent/scripts/generate-sdk-operation-inventory.ts @@ -35,6 +35,8 @@ const LOCKED_EXCLUSIONS: Readonly> = { "slash_command:transcript": "visual/local-only transcript viewer, not a user-facing SDK control seam", "slash_command:sessions": "visual/local-only sessions dashboard, not a user-facing SDK control seam", "agent_session:constructor": "internal accessor/plumbing, not a user-facing control seam", + "agent_session:materializeActiveDefaultModelProfileAssignment": + "internal extension selection materialization seam, not a user-facing SDK control seam", "agent_session:registerToolSessionCleanup": "internal tool lifecycle cleanup registration, not a user-facing SDK control seam", "agent_session:registerToolSessionTransitionCleanup": @@ -129,12 +131,24 @@ const LOCKED_EXCLUSIONS: Readonly> = { "internal owner-recovery staged restore builder after durable claims/fencing, never a user-facing SDK operation", "agent_session:setActiveModelProfile": "internal accessor/plumbing, not a user-facing control seam", "agent_session:getActiveModelProfile": "internal accessor/plumbing, not a user-facing control seam", + "agent_session:clearSessionOnlyModelProfileState": + "internal session-scoped profile lifecycle plumbing behind the reviewed model.set seam, not an independent public SDK operation", + "agent_session:noteProfileInstalledOverrides": + "internal session-scoped profile lifecycle plumbing behind the reviewed model.set seam, not an independent public SDK operation", + "agent_session:clearProfileInstalledOverrides": + "internal session-scoped profile lifecycle plumbing behind the reviewed model.set seam, not an independent public SDK operation", + "agent_session:getProfileInstalledOverrideKeys": + "internal session-scoped profile lifecycle accessor behind the reviewed model.set seam, not an independent public SDK operation", "agent_session:getSessionDefaultModelSelector": "internal accessor/plumbing, not a user-facing control seam", "agent_session:recordResumeDefaultModel": "internal accessor/plumbing, not a user-facing control seam", "agent_session:resolveConfiguredDefaultModel": "internal accessor/plumbing, not a user-facing control seam", "agent_session:setModelTemporary": "internal accessor/plumbing, not a user-facing control seam", "agent_session:setModelTemporaryForControl": "internal Telegram control wrapper over the reviewed model.set seam, not an independent public SDK operation", + "agent_session:setDefaultModelProfileForControl": + "internal control wrapper behind the reviewed model.set seam (session-scoped synthetic gajae-code selection), not an independent public SDK operation", + "agent_session:withSdkControlMutation": + "internal session admission wrapper for the reviewed config.patch seam, not an independent public SDK operation", "agent_session:setThinkingLevelForControl": "internal Telegram control wrapper over the reviewed thinking.set seam, not an independent public SDK operation", "agent_session:getThinkingScopeForControl": diff --git a/packages/coding-agent/src/cli/fast-help.ts b/packages/coding-agent/src/cli/fast-help.ts index d70d8ea40d..d1c3895b4a 100644 --- a/packages/coding-agent/src/cli/fast-help.ts +++ b/packages/coding-agent/src/cli/fast-help.ts @@ -54,6 +54,7 @@ Environment Variables: KILO_API_KEY - Kilo Gateway models MISTRAL_API_KEY - Mistral models ZAI_API_KEY - z.ai models (ZhipuAI/GLM) + JUNIE_API_KEY - JetBrains AI models (Junie; Claude via JetBrains AI) MINIMAX_API_KEY - MiniMax models OPENCODE_API_KEY - OpenCode Zen/OpenCode Go models CURSOR_ACCESS_TOKEN - Cursor AI models diff --git a/packages/coding-agent/src/config/model-bindings-applier.ts b/packages/coding-agent/src/config/model-bindings-applier.ts index f8c69ea1ac..e34aa881cf 100644 --- a/packages/coding-agent/src/config/model-bindings-applier.ts +++ b/packages/coding-agent/src/config/model-bindings-applier.ts @@ -17,6 +17,16 @@ export class ModelBindingsApplier { #lastAppliedRoles = new Map(); #lastAppliedAgentOverrides = new Map(); + /** The currently configured bindings (as installed at startup), for baseline lookup. */ + getBindings(): ConfiguredModelBindings | undefined { + return ( + this.#bindings && { + modelRoles: this.#cloneBindings(this.#bindings.modelRoles), + agentModelOverrides: this.#cloneBindings(this.#bindings.agentModelOverrides), + } + ); + } + setBindings(bindings: ConfiguredModelBindings | undefined): void { this.#bindings = bindings && { modelRoles: this.#cloneBindings(bindings.modelRoles), @@ -33,6 +43,56 @@ export class ModelBindingsApplier { this.apply(); } + /** + * Re-assert configured bindings into the target override slots, bypassing + * the user-edit-preservation heuristic. Used after a session-scoped profile + * reset removes profile-installed keys, so configured role/agent routing is + * restored exactly as it was installed at startup. + */ + forceApplyTo(targetSettings: Settings): void { + if (this.#targetSettings && this.#targetSettings !== targetSettings) { + this.#restoreTarget(this.#targetSettings); + this.#clearTargetLifecycle(); + } + this.#targetSettings = targetSettings; + const bindings = this.#bindings; + if (!targetSettings) return; + const modelRoles = { ...(targetSettings.get("modelRoles") ?? {}) }; + this.#forceSync( + modelRoles, + bindings?.modelRoles ?? {}, + this.#appliedRoles, + this.#roleBaselines, + this.#lastAppliedRoles, + ); + targetSettings.override("modelRoles", modelRoles); + const agentOverrides = { ...(targetSettings.get("task.agentModelOverrides") ?? {}) }; + this.#forceSync( + agentOverrides, + bindings?.agentModelOverrides ?? {}, + this.#appliedAgentOverrides, + this.#agentBaselines, + this.#lastAppliedAgentOverrides, + ); + targetSettings.override("task.agentModelOverrides", agentOverrides); + } + + #forceSync( + target: Record, + configured: Record, + applied: Set, + baselines: Map, + lastApplied: Map, + ): void { + for (const [key, value] of Object.entries(configured)) { + if (!baselines.has(key)) baselines.set(key, this.#clone(target[key])); + target[key] = this.#clone(value)!; + lastApplied.set(key, this.#clone(value)!); + } + applied.clear(); + for (const key of Object.keys(configured)) applied.add(key); + } + apply(): void { const targetSettings = this.#targetSettings; if (!targetSettings) return; diff --git a/packages/coding-agent/src/config/model-profile-activation.ts b/packages/coding-agent/src/config/model-profile-activation.ts index 037650ea21..d48c2f129f 100644 --- a/packages/coding-agent/src/config/model-profile-activation.ts +++ b/packages/coding-agent/src/config/model-profile-activation.ts @@ -34,6 +34,16 @@ type ModelProfileActivationSession = Pick< setModelTemporary?: AgentSession["setModelTemporary"]; setActiveModelProfile?: (name: string | undefined) => void; getActiveModelProfile?: () => string | undefined; + /** Record which runtime override keys this activation installed (session-scoped). */ + noteProfileInstalledOverrides?: ( + modelRoles: readonly string[], + agentModelOverrides: readonly string[], + preProfileModel: Model | undefined, + ) => void; + /** Drop the recorded profile-installed override keys (e.g. after materialization). */ + clearProfileInstalledOverrides?: () => void; + /** Current profile-installed override keys, for deriving the activation base. */ + getProfileInstalledOverrideKeys?: () => { modelRoles: readonly string[]; agentModelOverrides: readonly string[] }; getSessionDefaultModelSelector?: () => string | undefined; recordResumeDefaultModel?: (selector: string) => void; seedDefaultFallbackResolution?: (activeIndex: number, skips: Array<{ selector: string; reason: string }>) => void; @@ -52,7 +62,7 @@ export interface PrepareModelProfileActivationOptions { | "getCanonicalVariants" | "getCanonicalId" > & { getError?: ModelRegistry["getError"] }; - settings: Pick; + settings: Pick; profileName: string; } export interface ApplyModelProfileActivationOptions { @@ -67,6 +77,8 @@ export interface PreparedModelProfileActivation { previousThinkingLevel: ThinkingLevel | undefined; previousAgentModelOverrides: Record; previousModelRoles: Record; + baseAgentModelOverrides: Record; + baseModelRoles: Record; previousDefaultChain: readonly string[] | undefined; defaultModel: Model | undefined; defaultThinkingLevel: ThinkingLevel | undefined; @@ -92,7 +104,7 @@ export interface MaterializeModelProfileAssignmentOptions { session: Pick< ModelProfileActivationSession, "model" | "thinkingLevel" | "getConfiguredModelChain" | "setActiveModelProfile" | "getActiveModelProfile" - >; + > & { clearProfileInstalledOverrides?: () => void }; settings: Pick; role: GjcModelAssignmentTargetId; selector: string; @@ -102,7 +114,7 @@ export interface MaterializeModelProfileAssignmentsOptions { session: Pick< ModelProfileActivationSession, "model" | "thinkingLevel" | "getConfiguredModelChain" | "setActiveModelProfile" | "getActiveModelProfile" - >; + > & { clearProfileInstalledOverrides?: () => void }; settings: Pick; assignments: ReadonlyMap | Partial>; } @@ -183,6 +195,7 @@ export function materializeActiveModelProfileAssignment(options: MaterializeMode options.settings.override("modelRoles", nextModelRoles); options.settings.override("task.agentModelOverrides", nextAgentModelOverrides); options.session.setActiveModelProfile?.(undefined); + options.session.clearProfileInstalledOverrides?.(); return true; } @@ -218,6 +231,7 @@ export function materializeActiveModelProfileAssignments(options: MaterializeMod options.settings.override("modelRoles", nextModelRoles); options.settings.override("task.agentModelOverrides", nextAgentModelOverrides); options.session.setActiveModelProfile?.(undefined); + options.session.clearProfileInstalledOverrides?.(); return true; } @@ -448,6 +462,20 @@ export async function prepareModelProfileActivation( previousThinkingLevel: options.session.thinkingLevel, previousAgentModelOverrides: { ...options.settings.get("task.agentModelOverrides") }, previousModelRoles: { ...options.settings.get("modelRoles") }, + // The activation replaces only the previously installed profile-owned + // keys: derive the base from the effective map (durable + project + + // bindings + non-profile overrides) minus those keys, so project- or + // globally-scoped role overrides the new profile omits are preserved. + baseAgentModelOverrides: Object.fromEntries( + Object.entries(options.settings.get("task.agentModelOverrides") ?? {}).filter( + ([key]) => !(options.session.getProfileInstalledOverrideKeys?.().agentModelOverrides ?? []).includes(key), + ), + ), + baseModelRoles: Object.fromEntries( + Object.entries(options.settings.get("modelRoles") ?? {}).filter( + ([key]) => !(options.session.getProfileInstalledOverrideKeys?.().modelRoles ?? []).includes(key), + ), + ), previousDefaultChain: options.session.getConfiguredModelChain("default"), defaultModel, @@ -470,7 +498,7 @@ export async function applyPreparedModelProfileActivation( const previousThinkingLevel = prepared.previousThinkingLevel; const previousAgentModelOverrides = prepared.previousAgentModelOverrides; const previousModelRoles = prepared.previousModelRoles; - const previousPersistedDefault = prepared.settings.get("modelProfile.default"); + const previousPersistedDefault = prepared.settings.getGlobal("modelProfile.default"); const previousDefaultThinkingLevel = prepared.settings.get("defaultThinkingLevel"); const previousActiveModelProfile = prepared.previousActiveModelProfile; const previousSessionDefaultModel = prepared.previousSessionDefaultModel; @@ -511,16 +539,17 @@ export async function applyPreparedModelProfileActivation( modelChanged = true; } if (Object.keys(prepared.modelRoles).length > 0) { - prepared.settings.override("modelRoles", { ...previousModelRoles, ...prepared.modelRoles }); + prepared.settings.override("modelRoles", { ...prepared.baseModelRoles, ...prepared.modelRoles }); modelRolesChanged = true; } - if (Object.keys(prepared.agentModelOverrides).length > 0) { - prepared.settings.override("task.agentModelOverrides", { - ...previousAgentModelOverrides, - ...prepared.agentModelOverrides, - }); - overridesChanged = true; - } + // Always reinstall the agent role layer from the durable base plus the + // new profile's roles: a default-only or role-free successor must drop + // the previous profile's role-agent mappings rather than inheriting them. + prepared.settings.override("task.agentModelOverrides", { + ...prepared.baseAgentModelOverrides, + ...prepared.agentModelOverrides, + }); + overridesChanged = true; if (options.persistDefault) { prepared.settings.set("modelRoles", {}); prepared.settings.set("task.agentModelOverrides", {}); @@ -533,6 +562,12 @@ export async function applyPreparedModelProfileActivation( await prepared.settings.flush(); } prepared.session.setActiveModelProfile?.(prepared.profileName); + prepared.session.noteProfileInstalledOverrides?.( + Object.keys(prepared.modelRoles), + Object.keys(prepared.agentModelOverrides), + // Snapshotted before this activation replaced the runtime model. + previousModel, + ); } catch (error) { if (defaultChanged) { prepared.settings.set("modelProfile.default", previousPersistedDefault); diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index cd178fa4d6..7cf2c7156e 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -47,7 +47,7 @@ import type { AuthStorage, OAuthCredential } from "../session/auth-storage"; import type { ActiveSearchModelContext, WebSearchMode } from "../web/search/types"; import { type ConfigError, ConfigFile } from "./config-file"; import { isAuthenticated, kNoAuth } from "./model-auth"; -import { ModelBindingsApplier } from "./model-bindings-applier"; +import { type ConfiguredModelBindings, ModelBindingsApplier } from "./model-bindings-applier"; import { ModelDiscoveryManager, type ProviderDiscoveryState } from "./model-discovery-manager"; export type { ProviderDiscoveryState, ProviderDiscoveryStatus } from "./model-discovery-manager"; @@ -1979,6 +1979,20 @@ export class ModelRegistry { this.#modelBindingsApplier.applyTo(targetSettings); } + /** + * Re-assert configured modelBindings into the target override slots after a + * session-scoped profile reset removed profile-installed keys. Bypasses the + * user-edit heuristic so the startup role/agent routing is restored. + */ + reapplyConfiguredModelBindings(targetSettings: Settings): void { + this.#modelBindingsApplier.forceApplyTo(targetSettings); + } + + /** The currently configured modelBindings, for pre-profile baseline lookup. */ + getConfiguredModelBindings(): ConfiguredModelBindings | undefined { + return this.#modelBindingsApplier.getBindings(); + } + async #refreshRuntimeDiscoveries( strategy: ModelRefreshStrategy, providerFilter?: ReadonlySet, @@ -3109,7 +3123,10 @@ export class ModelRegistry { } #applyHardcodedModelPolicies(models: Model[]): Model[] { return models.map(model => { - if (model.id !== "gpt-5.4" || model.provider === "github-copilot") { + // `github-copilot` and `jetbrains-junie` both serve GPT-5.4 through their own + // gateway, which enforces a smaller prompt budget than the first-party 1M + // figure (Junie's is a probed 922K). Their bundled values are measured. + if (model.id !== "gpt-5.4" || model.provider === "github-copilot" || model.provider === "jetbrains-junie") { return model; } const overrides = this.#modelOverrides.get(model.provider)?.get(model.id); @@ -3201,6 +3218,11 @@ export class ModelRegistry { return this.#models; } + /** Provider ids declared in models.yml, including override-only providers. */ + getConfiguredProviderIds(): readonly string[] { + return [...this.#configuredProviderIds]; + } + #isModelAvailable(model: Model, disabledProviders = getDisabledProviderIdsFromSettings()): boolean { return ( !disabledProviders.has(model.provider) && diff --git a/packages/coding-agent/src/config/provider-ranking.ts b/packages/coding-agent/src/config/provider-ranking.ts index 03237af3ea..fd1384b4bb 100644 --- a/packages/coding-agent/src/config/provider-ranking.ts +++ b/packages/coding-agent/src/config/provider-ranking.ts @@ -57,6 +57,7 @@ export const FAMOUS_PROVIDER_ORDER: readonly string[] = [ "bizrouter", "mara", "github-copilot", + "jetbrains-junie", "cursor", ]; diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 9892473e23..144a49895e 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -3842,6 +3842,67 @@ function validSettingValue(definition: (typeof SETTINGS_SCHEMA)[SettingPath], va ); } +/** + * Validate an external (SDK `config.patch`) path/value set against the + * settings schema before any durable write. Dotted sub-paths of record + * settings (e.g. `modelRoles.default`) are validated against the record's + * value schema. Returns the offending entries so the caller can reject the + * whole patch without durable side effects. + */ +export function validateSettingPatch(patch: Record): Array<{ path: string; detail: string }> { + const issues: Array<{ path: string; detail: string }> = []; + const knownPaths = new Set(Object.keys(SETTINGS_SCHEMA)); + for (const [path, value] of Object.entries(patch)) { + const definition = SETTINGS_SCHEMA[path as SettingPath]; + if (!definition) { + const recordParent = [...knownPaths].find(known => known !== path && path.startsWith(`${known}.`)); + if (!recordParent) { + issues.push({ path, detail: "Setting is not recognized by this version." }); + continue; + } + const parentDef = SETTINGS_SCHEMA[recordParent as SettingPath]; + if (parentDef.type !== "record" || !("valueSchema" in parentDef) || !parentDef.valueSchema) { + issues.push({ path, detail: "Setting is not a valid record sub-path." }); + continue; + } + if ( + parentDef.valueSchema.type === "model-selector-value" && + !(typeof value === "string" || (Array.isArray(value) && value.every(item => typeof item === "string"))) + ) { + issues.push({ path, detail: "Expected model-selector-value." }); + } + continue; + } + if (!validSettingValue(definition, value)) { + // `Expected array.` is wrong for a real array carrying bad elements, and an + // SDK client reaching this through `config.patch` cannot act on it. Name the + // element constraint that actually failed. + const arrayItemEnum = + definition.type === "array" && Array.isArray(value) && "items" in definition + ? definition.items?.enum + : undefined; + const detail = arrayItemEnum + ? `Expected array items to be one of: ${arrayItemEnum.join(", ")}.` + : definition.type === "array" && Array.isArray(value) + ? "Expected array items to be strings." + : `Expected ${definition.type}.`; + issues.push({ path, detail }); + continue; + } + if (definition.type === "record" && "valueSchema" in definition && definition.valueSchema) { + for (const [key, entry] of Object.entries(value as Record)) { + if ( + definition.valueSchema.type === "model-selector-value" && + !(typeof entry === "string" || (Array.isArray(entry) && entry.every(item => typeof item === "string"))) + ) { + issues.push({ path: `${path}.${key}`, detail: "Expected model-selector-value." }); + } + } + } + } + return issues; +} + /** Coerce supported scalar legacy values and report unknown or invalid settings without dropping them. */ export function reconcileSettingsSchema(raw: Record): { settings: Record; diff --git a/packages/coding-agent/src/extensibility/extensions/compact-handler.ts b/packages/coding-agent/src/extensibility/extensions/compact-handler.ts index 35af7b6527..8037f3f31c 100644 --- a/packages/coding-agent/src/extensibility/extensions/compact-handler.ts +++ b/packages/coding-agent/src/extensibility/extensions/compact-handler.ts @@ -25,6 +25,12 @@ export async function runExtensionCompact( interface SetModelCapableSession { modelRegistry: { getApiKey(model: Model): Promise }; setModel(model: Model, role?: string, options?: { cause?: string }): Promise; + /** Persist effective profile roles and clear its marker for a concrete default selection. */ + materializeActiveDefaultModelProfileAssignment?(model: Model): boolean; + /** Drop a session-only profile marker and its runtime role overrides. */ + clearSessionOnlyModelProfileState?(): void; + /** Fallback marker clear for legacy session adapters. */ + setActiveModelProfile?(name: string | undefined): void; } /** @@ -36,5 +42,13 @@ export async function runExtensionSetModel(session: SetModelCapableSession, mode const key = await session.modelRegistry.getApiKey(model); if (!key) return false; await session.setModel(model, "default", { cause: "user-selection" }); + // A durable profile is replaced by materializing its effective assignments + // (otherwise a restart reapplies modelProfile.default and restores the + // profile the caller just replaced); a session-only marker is dropped + // together with its runtime role overrides. + if (!session.materializeActiveDefaultModelProfileAssignment?.(model)) { + if (session.clearSessionOnlyModelProfileState) session.clearSessionOnlyModelProfileState(); + else session.setActiveModelProfile?.(undefined); + } return true; } diff --git a/packages/coding-agent/src/extensibility/extensions/loader.ts b/packages/coding-agent/src/extensibility/extensions/loader.ts index 07ed1b64b0..6791fc1fa5 100644 --- a/packages/coding-agent/src/extensibility/extensions/loader.ts +++ b/packages/coding-agent/src/extensibility/extensions/loader.ts @@ -305,8 +305,12 @@ class ConcreteExtensionAPI implements ExtensionAPI, IExtensionRuntime { return this.runtime.setThinkingVisibilityForControl(visibility, persist); } - setModelTemporaryForControl(model: Model, expectedSessionId?: string): Promise { - return this.runtime.setModelTemporaryForControl(model, expectedSessionId); + setModelTemporaryForControl( + model: Model, + expectedSessionId?: string, + thinkingLevel?: ThinkingLevel, + ): Promise { + return this.runtime.setModelTemporaryForControl(model, expectedSessionId, thinkingLevel); } fetchUsageReportsForControl(): Promise { diff --git a/packages/coding-agent/src/extensibility/extensions/runner.ts b/packages/coding-agent/src/extensibility/extensions/runner.ts index 3275cda417..24f3bf61d7 100644 --- a/packages/coding-agent/src/extensibility/extensions/runner.ts +++ b/packages/coding-agent/src/extensibility/extensions/runner.ts @@ -225,6 +225,9 @@ export class ExtensionRunner { #getResolveToolFn: ExtensionContext["resolveTool"] = () => undefined; #cycleModelFn: ExtensionContextActions["cycleModel"] = undefined; #setModelProfileFn: ExtensionContextActions["setModelProfile"] = undefined; + #setDefaultModelProfileFn: ExtensionContextActions["setDefaultModelProfile"] = undefined; + #getActiveModelProfileFn: ExtensionContextActions["getActiveModelProfile"] = undefined; + #withSdkControlMutationFn: ExtensionContextActions["withSdkControlMutation"] = undefined; #cycleThinkingLevelFn: ExtensionContextActions["cycleThinkingLevel"] = undefined; #setQueueModeFn: ExtensionContextActions["setQueueMode"] = undefined; #getSkillStateFn: ExtensionContextActions["getSkillState"] = undefined; @@ -345,6 +348,9 @@ export class ExtensionRunner { this.#getResolveToolFn = contextActions.resolveTool ?? (() => undefined); this.#cycleModelFn = contextActions.cycleModel; this.#setModelProfileFn = contextActions.setModelProfile; + this.#setDefaultModelProfileFn = contextActions.setDefaultModelProfile; + this.#getActiveModelProfileFn = contextActions.getActiveModelProfile; + this.#withSdkControlMutationFn = contextActions.withSdkControlMutation; this.#cycleThinkingLevelFn = contextActions.cycleThinkingLevel; this.#setQueueModeFn = contextActions.setQueueMode; this.#getSkillStateFn = contextActions.getSkillState; @@ -619,6 +625,10 @@ export class ExtensionRunner { resolveTool: name => this.#getResolveToolFn(name), cycleModel: async () => await this.#cycleModelFn?.(), setModelProfile: async name => (await this.#setModelProfileFn?.(name)) ?? false, + setDefaultModelProfile: async (name, options) => + (await this.#setDefaultModelProfileFn?.(name, options)) ?? { changed: false, id: name }, + getActiveModelProfile: () => this.#getActiveModelProfileFn?.(), + withSdkControlMutation: body => this.#withSdkControlMutationFn?.(body) ?? body(), cycleThinkingLevel: () => this.#cycleThinkingLevelFn?.(), setQueueMode: (kind, mode) => this.#setQueueModeFn?.(kind, mode) ?? false, invokeSkill: async (name, args) => await this.#invokeSkillFn?.(name, args), @@ -640,6 +650,9 @@ export class ExtensionRunner { sdkBindings: () => [ ...(this.#cycleModelFn ? ["cycleModel"] : []), ...(this.#setModelProfileFn ? ["setModelProfile"] : []), + ...(this.#setDefaultModelProfileFn ? ["setDefaultModelProfile"] : []), + ...(this.#getActiveModelProfileFn ? ["getActiveModelProfile"] : []), + ...(this.#withSdkControlMutationFn ? ["withSdkControlMutation"] : []), ...(this.#cycleThinkingLevelFn ? ["cycleThinkingLevel"] : []), ...(this.#setQueueModeFn ? ["setQueueMode"] : []), ...(this.#getSkillStateFn ? ["getSkillState"] : []), diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index c63134efc7..ab34030040 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -387,6 +387,21 @@ export interface ExtensionContext { /** Session control seams used by the SDK host. */ cycleModel(): Promise<{ model: Model; thinkingLevel: ThinkingLevel | undefined } | undefined>; setModelProfile?(name: string): Promise; + /** Persist a model profile as the global default (SDK host seam). */ + setDefaultModelProfile?( + name: string, + options?: { + persistDefault?: boolean; + thinkingLevelOverride?: ThinkingLevel; + /** Internal SDK host hooks invoked inside the profile activation admission. */ + onBeforeActivation?: () => void; + onAfterActivation?: () => void; + }, + ): Promise; + /** The in-session active-profile marker; sole source of logical current state. */ + getActiveModelProfile?(): string | undefined; + /** Run a control-surface mutation inside the session admission boundary. */ + withSdkControlMutation?(body: () => Promise): Promise; cycleThinkingLevel(): ThinkingLevel | undefined; setQueueMode(kind: "steering" | "follow_up" | "interrupt", mode: unknown): boolean; getSkillState(): unknown; @@ -1210,7 +1225,11 @@ export interface ExtensionAPI { setThinkingVisibilityForControl(visibility: "visible" | "hidden", persist: boolean): Promise; /** Set the model for this session only. Returns false when it is unavailable. */ - setModelTemporaryForControl(model: Model, expectedSessionId?: string): Promise; + setModelTemporaryForControl( + model: Model, + expectedSessionId?: string, + thinkingLevel?: ThinkingLevel, + ): Promise; /** Fetch provider usage through the session's canonical provider resolution. */ fetchUsageReportsForControl(): Promise; @@ -1400,10 +1419,20 @@ export type SetThinkingVisibilityForControlHandler = ( persist: boolean, ) => Promise; -export type SetModelTemporaryForControlHandler = (model: Model, expectedSessionId?: string) => Promise; +export type SetModelTemporaryForControlHandler = ( + model: Model, + expectedSessionId?: string, + thinkingLevel?: ThinkingLevel, +) => Promise; -export type FetchUsageReportsForControlHandler = () => Promise; +/** Result of activating a model profile as the global default from a control surface. */ +export interface DefaultModelProfileActivationResult { + changed: boolean; + /** The canonical (alias-resolved) profile id. */ + id: string; +} +export type FetchUsageReportsForControlHandler = () => Promise; export type GetThinkingScopeForControlHandler = () => "session" | "global config"; export type GetThinkingLevelHandler = () => ThinkingLevel | undefined; @@ -1477,6 +1506,18 @@ export interface ExtensionContextActions { /** Session control and query seams exposed to the per-session SDK host. */ cycleModel?: () => Promise<{ model: Model; thinkingLevel: ThinkingLevel | undefined } | undefined>; setModelProfile?: (name: string) => Promise; + setDefaultModelProfile?: ( + name: string, + options?: { + persistDefault?: boolean; + thinkingLevelOverride?: ThinkingLevel; + /** Internal SDK host hooks invoked inside the profile activation admission. */ + onBeforeActivation?: () => void; + onAfterActivation?: () => void; + }, + ) => Promise; + getActiveModelProfile?: () => string | undefined; + withSdkControlMutation?: (body: () => Promise) => Promise; cycleThinkingLevel?: () => ThinkingLevel | undefined; setQueueMode?: (kind: "steering" | "follow_up" | "interrupt", mode: unknown) => boolean; getSkillState?: () => unknown; diff --git a/packages/coding-agent/src/gjc-runtime/gc-render.ts b/packages/coding-agent/src/gjc-runtime/gc-render.ts index b8939b5ae8..892e8a797c 100644 --- a/packages/coding-agent/src/gjc-runtime/gc-render.ts +++ b/packages/coding-agent/src/gjc-runtime/gc-render.ts @@ -72,6 +72,23 @@ export function buildGcReportText(report: GcReport): string { lines.push(""); } + if (report.session_scope) { + const scope = report.session_scope; + const mib = (bytes: number) => (bytes / (1024 * 1024)).toFixed(1); + const headline = + scope.status === "over_limit" + ? "Session scope is OVER the managed budget — new sessions in this directory will fail to start" + : "Session scope is approaching the managed budget"; + lines.push(headline); + lines.push( + ` ${mib(scope.total_bytes)} MiB of ${mib(scope.limit_bytes)} MiB across ${scope.entries} entries` + + `${scope.truncated ? " (walk truncated; totals are a floor)" : ""}`, + ); + lines.push(` ${scope.path}`); + lines.push(" gc does not reclaim session records; move stale session directories out of the scope by hand."); + lines.push(""); + } + if (report.warnings.length > 0) { lines.push(`Warnings (${report.warnings.length})`); for (const warning of report.warnings) lines.push(` [${warning.store}/${warning.scope}] ${warning.message}`); diff --git a/packages/coding-agent/src/gjc-runtime/gc-runtime.ts b/packages/coding-agent/src/gjc-runtime/gc-runtime.ts index 32b30db80a..a41a3deb59 100644 --- a/packages/coding-agent/src/gjc-runtime/gc-runtime.ts +++ b/packages/coding-agent/src/gjc-runtime/gc-runtime.ts @@ -18,6 +18,7 @@ import { SessionIndex } from "../sdk/broker/session-index"; import { UnsupportedStateVersionError } from "../sdk/broker/state-version"; import { buildGcReportText } from "./gc-render"; +import { collectSessionScopeUsage, type GcSessionScopeUsage, shouldReportSessionScope } from "./gc-session-scope"; export type GcStore = | "harness_leases" @@ -152,6 +153,8 @@ export interface GcReport { /** Partial-result notices that do not fail the run (e.g. walk caps). */ warnings: GcWarning[]; session_index?: GcSessionIndexHealth; + /** Managed-scope capacity, reported only when it is near or past the budget. */ + session_scope?: GcSessionScopeUsage; } export interface GcRunResult { @@ -384,6 +387,24 @@ function resolveGcAgentDir(env: NodeJS.ProcessEnv): string { return env.GJC_CODING_AGENT_DIR?.trim() || env.PI_CODING_AGENT_DIR?.trim() || getAgentDir(); } +/** + * Locate and measure the managed scope for `cwd`. + * + * Resolution is read-only (it never prepares or writes a scope), and any + * failure yields `undefined` so a capacity probe cannot fail a gc run. + */ +async function collectGcSessionScope(cwd: string, agentDir: string): Promise { + try { + const { resolveManagedScope } = await import("../session/internal/managed-session-scope"); + const { getSessionsDir } = await import("@gajae-code/utils"); + const resolved = resolveManagedScope({ cwd, agentDir, sessionsRoot: getSessionsDir(agentDir) }); + if (resolved.kind !== "resolved") return undefined; + return await collectSessionScopeUsage(resolved.scope.directoryPath); + } catch { + return undefined; + } +} + async function collectSessionIndexHealth(repair: boolean, agentDir: string): Promise { const index = new SessionIndex(agentDir); try { @@ -437,6 +458,8 @@ export async function runGjcGcCommand( const report = await collectGcReport(resolvedAdapters, ctx, parsed.prune); report.operation = parsed.repairSessionIndex ? "repair_session_index" : parsed.prune ? "prune" : "dry_run"; report.session_index = await collectSessionIndexHealth(parsed.repairSessionIndex, resolveGcAgentDir(env)); + const scopeUsage = await collectGcSessionScope(cwd, resolveGcAgentDir(env)); + if (scopeUsage && shouldReportSessionScope(scopeUsage)) report.session_scope = scopeUsage; const sessionIndexFailed = report.session_index?.status === "corrupt" || report.session_index?.status === "unsupported" || diff --git a/packages/coding-agent/src/gjc-runtime/gc-session-scope.ts b/packages/coding-agent/src/gjc-runtime/gc-session-scope.ts new file mode 100644 index 0000000000..90ae62f211 --- /dev/null +++ b/packages/coding-agent/src/gjc-runtime/gc-session-scope.ts @@ -0,0 +1,144 @@ +import type { Dirent } from "node:fs"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { MANAGED_ARTIFACT_MAX_TOTAL_BYTES } from "../session/internal/managed-session-storage"; + +/** + * Managed-scope capacity reporting for `gjc gc`. + * + * A managed session scope is snapshotted in full every time a session starts, + * and the snapshot fails closed once the tree exceeds the managed byte budget. + * The scope is filled by GJC's own session records, so a heavily used working + * directory can cross the budget without the operator doing anything unusual — + * and the first symptom is a launch that aborts, with no prior warning. + * + * `gjc gc` already reports on state the operator cannot see, so surfacing scope + * usage here gives that warning a home. This module only measures; nothing in + * the gc prune path acts on what it reports. + */ + +/** Directory walk ceiling. Bounds a pathological scope; reported when hit. */ +const MAX_WALK_ENTRIES = 200_000; + +/** Report a scope once it passes this share of the budget. */ +const NOTICE_RATIO = 0.75; + +export type GcSessionScopeStatus = "ok" | "approaching_limit" | "over_limit" | "unavailable"; + +export interface GcSessionScopeUsage { + status: GcSessionScopeStatus; + /** Absolute path of the managed scope for the current working directory. */ + path: string; + total_bytes: number; + limit_bytes: number; + entries: number; + /** True when the walk stopped at `MAX_WALK_ENTRIES`, so totals are a floor. */ + truncated: boolean; + reason?: string; +} + +interface WalkTotals { + bytes: number; + entries: number; + truncated: boolean; +} + +async function walk(root: string): Promise { + const totals: WalkTotals = { bytes: 0, entries: 0, truncated: false }; + const pending: string[] = [root]; + + while (pending.length > 0) { + const current = pending.pop(); + if (current === undefined) break; + + let dirents: Dirent[]; + try { + dirents = await fs.readdir(current, { withFileTypes: true }); + } catch { + // An unreadable subtree is reported as a floor, not a failure: a + // partial total still answers "am I near the budget?". + continue; + } + + for (const dirent of dirents) { + if (totals.entries >= MAX_WALK_ENTRIES) { + totals.truncated = true; + return totals; + } + totals.entries += 1; + const full = path.join(current, dirent.name); + if (dirent.isDirectory()) { + pending.push(full); + continue; + } + if (!dirent.isFile()) continue; + try { + const stat = await fs.lstat(full); + totals.bytes += stat.size; + } catch { + // Vanished mid-walk (a live session rotating records). Skip it. + } + } + } + + return totals; +} + +function classify(totalBytes: number, limitBytes: number): GcSessionScopeStatus { + if (totalBytes > limitBytes) return "over_limit"; + if (totalBytes >= limitBytes * NOTICE_RATIO) return "approaching_limit"; + return "ok"; +} + +/** + * Measure the managed scope directory backing `scopePath`. + * + * Never throws: an unreadable or absent scope is reported as `unavailable` so + * a capacity probe can never fail a gc run. + */ +export async function collectSessionScopeUsage( + scopePath: string, + limitBytes: number = MANAGED_ARTIFACT_MAX_TOTAL_BYTES, +): Promise { + const base: Pick = { + path: scopePath, + limit_bytes: limitBytes, + }; + try { + const stat = await fs.lstat(scopePath); + if (!stat.isDirectory()) { + return { + ...base, + status: "unavailable", + total_bytes: 0, + entries: 0, + truncated: false, + reason: "not_a_directory", + }; + } + } catch { + // No scope yet (first run in this directory) is not a problem to report. + return { + ...base, + status: "unavailable", + total_bytes: 0, + entries: 0, + truncated: false, + reason: "scope_not_found", + }; + } + + const totals = await walk(scopePath); + return { + ...base, + status: classify(totals.bytes, limitBytes), + total_bytes: totals.bytes, + entries: totals.entries, + truncated: totals.truncated, + }; +} + +/** Whether the usage is worth putting in front of an operator. */ +export function shouldReportSessionScope(usage: GcSessionScopeUsage): boolean { + return usage.status === "approaching_limit" || usage.status === "over_limit"; +} diff --git a/packages/coding-agent/src/modes/acp/acp-agent.ts b/packages/coding-agent/src/modes/acp/acp-agent.ts index 065b40a7e5..86cec4bd2e 100644 --- a/packages/coding-agent/src/modes/acp/acp-agent.ts +++ b/packages/coding-agent/src/modes/acp/acp-agent.ts @@ -47,6 +47,7 @@ import { resolveAcpFinalText } from "../../sdk/acp/final-text"; import { ACP_MCP_LIFECYCLE_TIMEOUT_MS, type SessionLifecycleMcpServer } from "../../sdk/acp/mcp"; import { ensureBroker } from "../../sdk/broker/ensure"; import { readSdkBrokerDiscovery, SdkClient, SdkClientError } from "../../sdk/client"; +import { SYNTHETIC_PROVIDER_ID } from "../../sdk/model-profile-model"; import type { SdkPromptTerminalOutcome } from "../../sdk/prompt-status"; import { buildToolCallStartUpdate, @@ -68,6 +69,15 @@ const SESSION_PAGE_SIZE = 50; const MAX_ACP_REPLAY_PAGES = 10_000; /** Bounded retention of settled prompt correlations so late duplicates stay closed. */ const SETTLED_PROMPT_CORRELATION_RETENTION = 16; +/** + * A cancelled prompt must still settle. The SDK acknowledges `turn.abort` before the + * aborted run publishes its normalized terminal, and agent-owned async work that + * outlives the turn can keep that terminal from ever arriving. A real terminal still + * wins inside this grace; past it ACP's mandated `cancelled` stop reason is published, + * so the client is never left holding a turn it cannot resolve or replace. + * Injectable in tests, never a user setting. + */ +const CANCEL_SETTLEMENT_GRACE_MS = 5_000; /** * Mirrors `REQUEST_FRAME_BYTES` in `crates/gjc-sdk/src/query.rs`: the SDK WebSocket * server sets `max_message_size`/`max_frame_size` to 256 KiB and closes the socket on @@ -488,7 +498,15 @@ function modelConfigOptions( for (const item of pageItems(query)) { const model = object(item); if (!model || typeof model.provider !== "string" || typeof model.id !== "string") continue; - if (activeProviders !== undefined && !activeProviders.has(model.provider)) continue; + // The reserved `gajae-code` namespace is a logical facade, not a real + // active provider: the Q10 projection already availability-filters the + // synthetic rows, so the Q29 provider filter must not drop them. + if ( + activeProviders !== undefined && + model.provider !== SYNTHETIC_PROVIDER_ID && + !activeProviders.has(model.provider) + ) + continue; const value = `${model.provider}/${model.id}`; options.set(value, typeof model.name === "string" ? model.name : value); } @@ -869,17 +887,23 @@ export class AcpAgent implements Agent { #clientCapabilities: ClientCapabilities | undefined; #broker: Promise | undefined; readonly #startupOptions: AcpStartupOptions | undefined; + readonly #cancelSettlementGraceMs: number; #disposed = false; #disposePromise: Promise | undefined; constructor( connection: AgentSideConnection, - options?: { agentDir?: string; startupOptions?: AcpStartupOptions } | unknown, + options?: { agentDir?: string; startupOptions?: AcpStartupOptions; cancelSettlementGraceMs?: number } | unknown, ) { this.#connection = connection; const candidate = object(options); this.#agentDir = typeof candidate?.agentDir === "string" ? candidate.agentDir : getAgentDir(); this.#startupOptions = parseAcpStartupOptions(candidate?.startupOptions); + this.#cancelSettlementGraceMs = + typeof candidate?.cancelSettlementGraceMs === "number" && + Number.isSafeInteger(candidate.cancelSettlementGraceMs) + ? candidate.cancelSettlementGraceMs + : CANCEL_SETTLEMENT_GRACE_MS; queueMicrotask(() => { if (connection.signal.aborted) { this.#beginDispose(); @@ -1277,6 +1301,43 @@ export class AcpAgent implements Agent { "abort_unacknowledged", "SDK did not acknowledge cancellation of the active prompt.", ); + // The acknowledgement proves the run was aborted, not that its terminal was + // published. Arm the bounded settlement so the turn cannot outlive the cancel. + this.#scheduleCancelSettlement(params.sessionId, record); + } + + /** + * `aborted: true` means the run is gone, so the pending prompt is already over even + * if no normalized terminal follows. Without this the waiter stays pending forever: + * the client's turn never resolves, its composer stays in the running phase, and + * every later `session/prompt` is refused with `conflict`. + */ + #scheduleCancelSettlement(id: string, record: SessionRecord): void { + const waiter = record.activePrompt; + if (!waiter || waiter.settled) return; + setTimeout(() => { + void this.#settleCancelledPrompt(id, record, waiter); + }, this.#cancelSettlementGraceMs).unref?.(); + } + + async #settleCancelledPrompt(id: string, record: SessionRecord, waiter: PromptWaiter): Promise { + // The authoritative terminal wins whenever it arrives in time; this only runs + // when nothing settled the prompt the client already asked to cancel. + if (this.#sessions.get(id) !== record || record.activePrompt !== waiter || waiter.settled) return; + record.activePrompt = undefined; + record.cancelRequested = false; + waiter.settled = true; + waiter.deferredFrames.length = 0; + waiter.terminal = undefined; + // A late terminal for this turn must stay closed rather than publish over a + // prompt the client has already been told is cancelled. + if (hasCompleteCorrelation(waiter.correlation)) { + record.settledPromptCorrelations.push(waiter.correlation); + while (record.settledPromptCorrelations.length > SETTLED_PROMPT_CORRELATION_RETENTION) + record.settledPromptCorrelations.shift(); + } + await this.#publishPromptPhaseIdle(id, record.adapter); + waiter.resolve({ stopReason: "cancelled" }); } async extMethod(method: string, params: JsonObject): Promise { @@ -1804,8 +1865,9 @@ export class AcpAgent implements Agent { typeof (event as { error?: { message?: unknown } }).error?.message === "string" ? (event as { error: { message: string } }).error.message : "the prompt terminal omitted a valid normalized outcome"; - this.#rejectPrompt( + await this.#rejectPrompt( record, + id, activePrompt, new AcpSdkAdapterError("connection_closed", `ACP prompt terminal was invalid: ${detail}`), ); @@ -1907,7 +1969,12 @@ export class AcpAgent implements Agent { if (activePrompt) this.#settlePrompt(record, activePrompt); } - #rejectPrompt(record: SessionRecord, waiter: PromptWaiter, error: AcpSdkAdapterError): void { + async #rejectPrompt( + record: SessionRecord, + id: string, + waiter: PromptWaiter, + error: AcpSdkAdapterError, + ): Promise { if (record.activePrompt !== waiter || waiter.settled) return; record.activePrompt = undefined; waiter.settled = true; @@ -1918,9 +1985,35 @@ export class AcpAgent implements Agent { while (record.settledPromptCorrelations.length > SETTLED_PROMPT_CORRELATION_RETENTION) record.settledPromptCorrelations.shift(); } + // The turn is over even though it ended badly, so the client's running phase has + // to be released. Skipping it here is what leaves a client composer spinning on a + // turn that will never produce another frame. + await this.#publishPromptPhaseIdle(id, record.adapter); waiter.reject(error); } + /** + * Publishes only the phase transition — no `context.get`/`session.metadata` queries — + * because an abnormal settlement has no trustworthy usage or title to report. Publish + * failures are swallowed: the turn is already settled, and escalating to session + * failure here would tear down a session the client can still use. + */ + async #publishPromptPhaseIdle(id: string, adapter: AcpSdkAdapter): Promise { + const record = this.#sessions.get(id); + if (!record || record.adapter !== adapter) return; + try { + await this.#connection.sessionUpdate({ + sessionId: id, + update: { + sessionUpdate: "session_info_update", + _meta: { gjcPhase: "idle", running: false, gjcRunning: false }, + }, + }); + } catch { + // The client transport is gone; there is no phase left to restore. + } + } + #settlePrompt(record: SessionRecord, waiter: PromptWaiter): void { if (record.activePrompt !== waiter || waiter.settled || !waiter.acknowledged || !waiter.terminal) return; // A terminal captured before acknowledgement is only this prompt's terminal when the diff --git a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts index 52df8f1aa8..cfbbf9cc2f 100644 --- a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts +++ b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts @@ -25,6 +25,7 @@ import type { SendUserMessageHandler, TerminalInputHandler, } from "../../extensibility/extensions"; +import { runExtensionSetModel } from "../../extensibility/extensions/compact-handler"; import { getSessionSlashCommands } from "../../extensibility/extensions/get-commands-handler"; import { HookEditorComponent } from "../../modes/components/hook-editor"; import { HookInputComponent } from "../../modes/components/hook-input"; @@ -36,6 +37,12 @@ import { stopInteractiveActivityIndicator, syncInteractiveActivityIndicator, } from "../../modes/types"; +import { + parseSyntheticModelId, + resolveSyntheticModelSelection, + SYNTHETIC_PROVIDER_ID, + syntheticNamespaceCollision, +} from "../../sdk/model-profile-model"; import { createReadonlySessionManager } from "../../session/session-manager"; import { parseThinkingLevel } from "../../thinking"; import type { TodoPhase } from "../../tools/todo-write"; @@ -159,18 +166,70 @@ export class ExtensionUiController { switch (operation) { case "model.set": { const selector = typeof input.id === "string" ? input.id : ""; + const rawThinkingLevel = typeof input.thinkingLevel === "string" ? input.thinkingLevel : undefined; + const hasThinkingLevel = rawThinkingLevel !== undefined; + const thinkingLevel = rawThinkingLevel === undefined ? undefined : parseThinkingLevel(rawThinkingLevel); + if (parseSyntheticModelId(selector) !== undefined) { + if ( + syntheticNamespaceCollision( + session.modelRegistry.getAll?.() ?? [], + session.modelRegistry.getConfiguredProviderIds?.() ?? [], + ) + ) + throw Object.assign( + new Error( + `The ${SYNTHETIC_PROVIDER_ID} namespace is reserved; synthetic preset selection is disabled while a provider of the same name is configured.`, + ), + { code: "invalid_input" }, + ); + // An absent thinking level is allowed (matches the generic + // model.set and the SDK contract); a supplied-but-unparseable or + // non-"off" value is rejected, and the override is passed only + // when the caller supplied it. + if ( + hasThinkingLevel && + (thinkingLevel === undefined || + thinkingLevel === ThinkingLevel.Inherit || + thinkingLevel !== ThinkingLevel.Off) + ) + throw Object.assign(new Error('model.set thinkingLevel for a synthetic profile must be "off".'), { + code: "invalid_input", + }); + const resolved = resolveSyntheticModelSelection( + selector, + session.modelRegistry.getModelProfiles(), + session.modelRegistry.getError?.(), + ); + await session.setDefaultModelProfileForControl(resolved.canonicalName, { + persistDefault: false, + ...(hasThinkingLevel ? { thinkingLevelOverride: ThinkingLevel.Off } : {}), + }); + return { + provider: SYNTHETIC_PROVIDER_ID, + modelId: resolved.canonicalName, + thinkingLevel: session.thinkingLevel, + }; + } const slashIndex = selector.indexOf("/"); const model = slashIndex > 0 ? session.modelRegistry.find(selector.slice(0, slashIndex), selector.slice(slashIndex + 1)) : undefined; - const thinkingLevel = - typeof input.thinkingLevel === "string" ? parseThinkingLevel(input.thinkingLevel) : undefined; if (!model || !thinkingLevel || thinkingLevel === ThinkingLevel.Inherit) throw Object.assign(new Error("model.set requires a valid model id and concrete thinkingLevel."), { code: "invalid_input", }); - return await session.setDefaultModelSelection(model, thinkingLevel); + // Internal host hooks (never public SDK fields): the bus surface runs + // its Q13 config-shadow capture/reconcile inside this selection + // admission so a concurrent config.patch cannot race the snapshot. + return await session.setDefaultModelSelection(model, thinkingLevel, { + ...(typeof input.onBeforeMutation === "function" + ? { onBeforeMutation: input.onBeforeMutation as () => void } + : {}), + ...(typeof input.onAfterMutation === "function" + ? { onAfterMutation: input.onAfterMutation as () => void } + : {}), + }); } case "todo.replace": { const phases = input.items; @@ -497,12 +556,7 @@ export class ExtensionUiController { return tool ? { safeSummary: tool.safeSummary, safeSummaryFields: tool.safeSummaryFields } : undefined; }, setActiveTools: toolNames => this.ctx.session.setActiveToolsByName(toolNames), - setModel: async model => { - const key = await this.ctx.session.modelRegistry.getApiKey(model); - if (!key) return false; - await this.ctx.session.setModel(model, "default", { cause: "user-selection" }); - return true; - }, + setModel: model => runExtensionSetModel(this.ctx.session, model), getThinkingLevel: () => this.ctx.session.thinkingLevel, setThinkingLevel: (level, persist) => this.ctx.session.setThinkingLevel(level, persist), getThinkingVisibility: () => this.ctx.session.getThinkingVisibility(), @@ -511,8 +565,8 @@ export class ExtensionUiController { setThinkingLevelForControl: (level, persist) => this.ctx.session.setThinkingLevelForControl(level, persist), setThinkingVisibilityForControl: (visibility, persist) => this.ctx.session.setThinkingVisibilityForControl(visibility, persist), - setModelTemporaryForControl: (model, expectedSessionId) => - this.ctx.session.setModelTemporaryForControl(model, expectedSessionId), + setModelTemporaryForControl: (model, expectedSessionId, thinkingLevel) => + this.ctx.session.setModelTemporaryForControl(model, expectedSessionId, thinkingLevel), fetchUsageReportsForControl: () => this.ctx.session.fetchUsageReportsForControl(), getThinkingScopeForControl: () => this.ctx.session.getThinkingScopeForControl(), getCommands: () => getSessionSlashCommands(this.ctx.session), @@ -551,6 +605,9 @@ export class ExtensionUiController { clearContext: () => this.ctx.session.clearContext(), cycleModel: () => this.ctx.session.cycleModel(), setModelProfile: name => this.ctx.session.activateModelProfileForControl(name), + setDefaultModelProfile: (name, options) => this.ctx.session.setDefaultModelProfileForControl(name, options), + getActiveModelProfile: () => this.ctx.session.getActiveModelProfile(), + withSdkControlMutation: body => this.ctx.session.withSdkControlMutation(body), cycleThinkingLevel: () => this.ctx.session.cycleThinkingLevel(), setQueueMode: (kind, mode) => { if (kind === "steering" && (mode === "all" || mode === "one-at-a-time")) { @@ -816,12 +873,7 @@ export class ExtensionUiController { return tool ? { safeSummary: tool.safeSummary, safeSummaryFields: tool.safeSummaryFields } : undefined; }, setActiveTools: toolNames => this.ctx.session.setActiveToolsByName(toolNames), - setModel: async model => { - const key = await this.ctx.session.modelRegistry.getApiKey(model); - if (!key) return false; - await this.ctx.session.setModel(model, "default", { cause: "user-selection" }); - return true; - }, + setModel: model => runExtensionSetModel(this.ctx.session, model), getThinkingLevel: () => this.ctx.session.thinkingLevel, setThinkingLevel: (level, persist) => this.ctx.session.setThinkingLevel(level, persist), getThinkingVisibility: () => this.ctx.session.getThinkingVisibility(), @@ -830,8 +882,8 @@ export class ExtensionUiController { setThinkingLevelForControl: (level, persist) => this.ctx.session.setThinkingLevelForControl(level, persist), setThinkingVisibilityForControl: (visibility, persist) => this.ctx.session.setThinkingVisibilityForControl(visibility, persist), - setModelTemporaryForControl: (model, expectedSessionId) => - this.ctx.session.setModelTemporaryForControl(model, expectedSessionId), + setModelTemporaryForControl: (model, expectedSessionId, thinkingLevel) => + this.ctx.session.setModelTemporaryForControl(model, expectedSessionId, thinkingLevel), fetchUsageReportsForControl: () => this.ctx.session.fetchUsageReportsForControl(), getThinkingScopeForControl: () => this.ctx.session.getThinkingScopeForControl(), getCommands: () => getSessionSlashCommands(this.ctx.session), @@ -870,6 +922,9 @@ export class ExtensionUiController { clearContext: () => this.ctx.session.clearContext(), cycleModel: () => this.ctx.session.cycleModel(), setModelProfile: name => this.ctx.session.activateModelProfileForControl(name), + setDefaultModelProfile: (name, options) => this.ctx.session.setDefaultModelProfileForControl(name, options), + getActiveModelProfile: () => this.ctx.session.getActiveModelProfile(), + withSdkControlMutation: body => this.ctx.session.withSdkControlMutation(body), cycleThinkingLevel: () => this.ctx.session.cycleThinkingLevel(), setQueueMode: (kind, mode) => { if (kind === "steering" && (mode === "all" || mode === "one-at-a-time")) { diff --git a/packages/coding-agent/src/modes/runtime-init.ts b/packages/coding-agent/src/modes/runtime-init.ts index f2b4a7ee70..4266c8938c 100644 --- a/packages/coding-agent/src/modes/runtime-init.ts +++ b/packages/coding-agent/src/modes/runtime-init.ts @@ -11,6 +11,12 @@ import { ThinkingLevel } from "@gajae-code/agent-core"; import { runExtensionCompact, runExtensionSetModel } from "../extensibility/extensions/compact-handler"; import { getSessionSlashCommands } from "../extensibility/extensions/get-commands-handler"; import type { ExtensionError, ExtensionUIContext } from "../extensibility/extensions/types"; +import { + parseSyntheticModelId, + resolveSyntheticModelSelection, + SYNTHETIC_PROVIDER_ID, + syntheticNamespaceCollision, +} from "../sdk/model-profile-model"; import type { AgentSession } from "../session/agent-session"; import { parseThinkingLevel } from "../thinking"; @@ -81,8 +87,8 @@ export async function initializeExtensions(session: AgentSession, options: Initi setThinkingLevelForControl: (level, persist) => session.setThinkingLevelForControl(level, persist), setThinkingVisibilityForControl: (visibility, persist) => session.setThinkingVisibilityForControl(visibility, persist), - setModelTemporaryForControl: (model, expectedSessionId) => - session.setModelTemporaryForControl(model, expectedSessionId), + setModelTemporaryForControl: (model, expectedSessionId, thinkingLevel) => + session.setModelTemporaryForControl(model, expectedSessionId, thinkingLevel), fetchUsageReportsForControl: () => session.fetchUsageReportsForControl(), getThinkingScopeForControl: () => session.getThinkingScopeForControl(), getSessionName: () => session.sessionManager.getSessionName(), @@ -118,6 +124,9 @@ export async function initializeExtensions(session: AgentSession, options: Initi clearContext: () => session.clearContext(), cycleModel: () => session.cycleModel(), setModelProfile: name => session.activateModelProfileForControl(name), + setDefaultModelProfile: (name, options) => session.setDefaultModelProfileForControl(name, options), + getActiveModelProfile: () => session.getActiveModelProfile(), + withSdkControlMutation: body => session.withSdkControlMutation(body), cycleThinkingLevel: () => session.cycleThinkingLevel(), setQueueMode: (kind, mode) => { if (kind === "steering" && (mode === "all" || mode === "one-at-a-time")) { @@ -175,18 +184,72 @@ export async function initializeExtensions(session: AgentSession, options: Initi switch (operation) { case "model.set": { const selector = typeof input.id === "string" ? input.id : ""; + const rawThinkingLevel = typeof input.thinkingLevel === "string" ? input.thinkingLevel : undefined; + const hasThinkingLevel = rawThinkingLevel !== undefined; + const thinkingLevel = + rawThinkingLevel === undefined ? undefined : parseThinkingLevel(rawThinkingLevel); + if (parseSyntheticModelId(selector) !== undefined) { + if ( + syntheticNamespaceCollision( + session.modelRegistry.getAll?.() ?? [], + session.modelRegistry.getConfiguredProviderIds?.() ?? [], + ) + ) + throw Object.assign( + new Error( + `The ${SYNTHETIC_PROVIDER_ID} namespace is reserved; synthetic preset selection is disabled while a provider of the same name is configured.`, + ), + { code: "invalid_input" }, + ); + // An absent thinking level is allowed (matches the generic + // model.set and the SDK contract); a supplied-but-unparseable or + // non-"off" value is rejected, and the override is passed only + // when the caller supplied it. + if ( + hasThinkingLevel && + (thinkingLevel === undefined || + thinkingLevel === ThinkingLevel.Inherit || + thinkingLevel !== ThinkingLevel.Off) + ) + throw Object.assign( + new Error('model.set thinkingLevel for a synthetic profile must be "off".'), + { code: "invalid_input" }, + ); + const resolved = resolveSyntheticModelSelection( + selector, + session.modelRegistry.getModelProfiles(), + session.modelRegistry.getError?.(), + ); + await session.setDefaultModelProfileForControl(resolved.canonicalName, { + persistDefault: false, + ...(hasThinkingLevel ? { thinkingLevelOverride: ThinkingLevel.Off } : {}), + }); + return { + provider: SYNTHETIC_PROVIDER_ID, + modelId: resolved.canonicalName, + thinkingLevel: session.thinkingLevel, + }; + } const slashIndex = selector.indexOf("/"); const model = slashIndex > 0 ? session.modelRegistry.find(selector.slice(0, slashIndex), selector.slice(slashIndex + 1)) : undefined; - const thinkingLevel = - typeof input.thinkingLevel === "string" ? parseThinkingLevel(input.thinkingLevel) : undefined; if (!model || !thinkingLevel || thinkingLevel === ThinkingLevel.Inherit) throw Object.assign(new Error("model.set requires a valid model id and concrete thinkingLevel."), { code: "invalid_input", }); - return await session.setDefaultModelSelection(model, thinkingLevel); + // Internal host hooks (never public SDK fields): the bus surface runs + // its Q13 config-shadow capture/reconcile inside this selection + // admission so a concurrent config.patch cannot race the snapshot. + return await session.setDefaultModelSelection(model, thinkingLevel, { + ...(typeof input.onBeforeMutation === "function" + ? { onBeforeMutation: input.onBeforeMutation as () => void } + : {}), + ...(typeof input.onAfterMutation === "function" + ? { onAfterMutation: input.onAfterMutation as () => void } + : {}), + }); } case "todo.replace": { const phases = input.items; diff --git a/packages/coding-agent/src/sdk/bus/index.ts b/packages/coding-agent/src/sdk/bus/index.ts index e89a75cb62..863baf4a46 100644 --- a/packages/coding-agent/src/sdk/bus/index.ts +++ b/packages/coding-agent/src/sdk/bus/index.ts @@ -48,7 +48,7 @@ function sdkBusNatives(): NativeSdkBusBindings { type NotificationServer = NativeNotificationServer; import { $credentialEnv, logger, postmortem, VERSION } from "@gajae-code/utils"; -import { Settings } from "../../config/settings"; +import { Settings, validateSettingPatch } from "../../config/settings"; import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "../../extensibility/extensions"; import { INTERACTIVE_SELECTOR_RESUME_ORIGIN } from "../../extensibility/shared-events"; import { toAgentWireEventPayload } from "../../modes/shared/agent-wire/event-envelope"; @@ -85,6 +85,13 @@ import { createSdkSurfaceFactory, type SessionSdkHost, SessionSdkSessionRuntime, import { type ControlSurface, dispatchControl } from "../host/control"; import { CursorRegistry, QueryHandlers, RevisionStore, type SessionSurface } from "../host/query"; import type { SdkFrame } from "../host/types"; +import { + parseSyntheticModelId, + resolveSyntheticModelSelection, + SYNTHETIC_PROVIDER_ID, + syntheticModelInputError, + syntheticNamespaceCollision, +} from "../model-profile-model"; import { PROMPT_CLIENT_REF_MAX_LENGTH, type SdkPromptTerminalOutcome } from "../prompt-status"; import { OPERATIONS } from "../protocol/operation-registry"; import { @@ -2243,6 +2250,7 @@ function sdkQuerySurface( followupQueueDepth: 0, }), configOverrides: ReadonlyMap = new Map(), + settings: Settings | undefined = undefined, promptStatusLookup: (selector: { commandId?: string; turnId?: string; clientRef?: string }) => unknown = () => ({ status: "unknown", }), @@ -2257,6 +2265,7 @@ function sdkQuerySurface( getInstalledDefinitions, getLiveState, configOverrides, + settings, promptStatusLookup, skillStatusLookup, hostTools: () => getInstalledDefinitions("host_tools") !== undefined, @@ -2275,6 +2284,49 @@ function containsSecretConfigKey(value: unknown, seen = new Set()): bool ); } +function captureConfigOverridesShadow(settings: Settings, configOverrides: Map): Map { + const before = new Map(); + for (const key of configOverrides.keys()) { + try { + before.set(key, settings.get(key as never)); + } catch { + before.set(key, undefined); + } + } + return before; +} + +function reconcileConfigOverridesShadow( + settings: Settings, + configOverrides: Map, + before: ReadonlyMap, +): void { + for (const [key, prior] of before) { + let current: unknown; + try { + current = settings.get(key as never); + } catch { + current = undefined; + } + if (!deepStructuralEqual(current, prior)) configOverrides.delete(key); + } +} + +function deepStructuralEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true; + if (Array.isArray(left) && Array.isArray(right)) + return left.length === right.length && left.every((value, index) => deepStructuralEqual(value, right[index])); + if (left === null || right === null || typeof left !== "object" || typeof right !== "object") return false; + const leftRecord = left as Record; + const rightRecord = right as Record; + const leftKeys = Object.keys(leftRecord); + const rightKeys = Object.keys(rightRecord); + return ( + leftKeys.length === rightKeys.length && + leftKeys.every(key => deepStructuralEqual(leftRecord[key], rightRecord[key])) + ); +} + function sdkControlSurface( ctx: ExtensionContext, pendingInteractive: Map, @@ -2375,6 +2427,55 @@ function sdkControlSurface( if (!model) throw Object.assign(new Error(`Model ${id} was not found.`), { code: "invalid_input" }); return model; }; + /** + * `config.patch` records patched values in `configOverrides` so query + * readback shows them, but a serialized activation that rewrites the same + * setting (e.g. `modelRoles` cleared by persist-default activation) does not + * touch the shadow — leaving `config.list/get` reporting a stale patch as + * authoritative. After the admitted mutation completes, drop any shadowed + * key whose live settings value changed so the durable value wins. + */ + + /** + * Route a synthetic `gajae-code/` model selection into the + * session-scoped activation transaction. ACP model selection never writes a + * global profile default; persistence remains an explicit TUI choice. Only + * an absent or `off` thinking level is forwarded (synthetic rows advertise + * `validLevels: ["off"]`); any other level is rejected before admission. + * A user-defined provider under the reserved namespace fails closed rather + * than being shadowed. With a thinking level, the typed host surface returns + * the pinned `DefaultModelSelectionResult`-shaped result. + */ + const setSyntheticModel = async (id: string, requestedThinkingLevel: unknown) => { + const hasLevel = requestedThinkingLevel !== undefined; + const thinkingLevel = + typeof requestedThinkingLevel === "string" ? parseThinkingLevel(requestedThinkingLevel) : undefined; + if ( + hasLevel && + (!thinkingLevel || thinkingLevel === ThinkingLevel.Inherit || thinkingLevel !== ThinkingLevel.Off) + ) + throw syntheticModelInputError('model.set thinkingLevel for a synthetic profile must be "off".'); + const profiles = ctx.modelRegistry.getModelProfiles(); + const resolved = resolveSyntheticModelSelection(id, profiles, ctx.modelRegistry.getError?.()); + if (syntheticNamespaceCollision(ctx.modelRegistry.getAll(), ctx.modelRegistry.getConfiguredProviderIds?.() ?? [])) + throw syntheticModelInputError( + `The ${SYNTHETIC_PROVIDER_ID} namespace is reserved; synthetic preset selection is disabled while a provider of the same name is configured.`, + ); + const setDefaultModelProfile = ctx.setDefaultModelProfile; + if (!bindings.has("setDefaultModelProfile") || !setDefaultModelProfile) + return unavailable("model.set", "no default model-profile seam is installed")(); + await setDefaultModelProfile(resolved.canonicalName, { + persistDefault: false, + ...(hasLevel ? { thinkingLevelOverride: ThinkingLevel.Off } : {}), + }); + return hasLevel + ? { + provider: SYNTHETIC_PROVIDER_ID, + modelId: resolved.canonicalName, + thinkingLevel: ThinkingLevel.Off, + } + : { changed: true }; + }; const unavailablePerSession = (operation: string) => unavailable(operation, "the registry classifies it outside the per-session extension host"); const typed = (operation: string, input: Record = {}) => { @@ -2835,8 +2936,22 @@ function sdkControlSurface( }, replaceTodo: items => typed("todo.replace", { items }), setModel: async (id, requestedThinkingLevel) => { + if (parseSyntheticModelId(id) !== undefined) return setSyntheticModel(id, requestedThinkingLevel); const model = resolveModel(id); - if (requestedThinkingLevel === undefined) return { changed: await api.setModel(model) }; + if (requestedThinkingLevel === undefined) { + // The extension seam is not admission-bound, so serialize it (and the + // Q13 shadow capture/reconcile) against config.patch through the + // session admission boundary. + const run = async () => { + const shadowBefore = settings ? captureConfigOverridesShadow(settings, configOverrides) : undefined; + const changed = await api.setModel(model); + if (settings && shadowBefore) reconcileConfigOverridesShadow(settings, configOverrides, shadowBefore); + return { changed }; + }; + return typeof (ctx as Partial).withSdkControlMutation === "function" + ? ctx.withSdkControlMutation!(run) + : run(); + } const thinkingLevel = typeof requestedThinkingLevel === "string" ? parseThinkingLevel(requestedThinkingLevel) : undefined; if (!thinkingLevel || thinkingLevel === ThinkingLevel.Inherit) @@ -2844,7 +2959,21 @@ function sdkControlSurface( new Error("model.set thinkingLevel must be off, minimal, low, medium, high, xhigh, or max."), { code: "invalid_input" }, ); - return typed("model.set", { id: `${model.provider}/${model.id}`, thinkingLevel }); + // The typed concrete selection already admits internally; run the Q13 + // shadow capture/reconcile inside that same admission via internal + // hooks so a concurrent config.patch cannot race the snapshot. + let shadowBefore: Map | undefined; + const capture = () => + (shadowBefore = settings ? captureConfigOverridesShadow(settings, configOverrides) : undefined); + const reconcile = () => { + if (settings && shadowBefore) reconcileConfigOverridesShadow(settings, configOverrides, shadowBefore); + }; + const result = await typed("model.set", { + id: `${model.provider}/${model.id}`, + thinkingLevel, + ...(settings ? { onBeforeMutation: capture, onAfterMutation: reconcile } : {}), + }); + return result; }, setModelProfile: async id => { if (!bindings.has("setModelProfile") || !ctx.setModelProfile) @@ -2908,12 +3037,28 @@ function sdkControlSurface( throw Object.assign(new Error("config.patch rejects secret fields at the SDK host."), { code: "invalid_input", }); + const patchIssues = validateSettingPatch(patch as Record); + if (patchIssues.length > 0) { + const detail = patchIssues.map(issue => `${issue.path} (${issue.detail})`).join("; "); + throw Object.assign(new Error(`config.patch rejects invalid settings: ${detail}`), { + code: "invalid_input", + }); + } if (!settings) return unavailable("config.patch", "configuration settings are unavailable for this session")(); - const entries = Object.entries(patch as Record); - for (const [key, value] of entries) settings.set(key as never, value as never); - for (const [key, value] of entries) configOverrides.set(key, value); - configRevision.current += 1; - return { patched: entries.map(([key]) => key), revision: String(configRevision.current) }; + const applyPatch = async () => { + const entries = Object.entries(patch as Record); + for (const [key, value] of entries) settings.set(key as never, value as never); + for (const [key, value] of entries) configOverrides.set(key, value); + configRevision.current += 1; + return { patched: entries.map(([key]) => key), revision: String(configRevision.current) }; + }; + // Serialize config mutations against synthetic profile activation and + // default-model selection so an interleaved patch can never be lost or + // clobbered by an activation rollback (plan criterion 8). + if (typeof (ctx as Partial).withSdkControlMutation === "function") { + return ctx.withSdkControlMutation!(applyPatch); + } + return applyPatch(); }, reloadRuntime: components => typed("runtime.reload", { components }), @@ -4355,6 +4500,7 @@ export function createNotificationsExtension( }; }, configOverrides, + settings, lookupPromptStatus, selector => kindReconciliation.lookup("skill", selector), ), @@ -4528,6 +4674,8 @@ export function createNotificationsExtension( }, ...(preparesExistingThread ? { readiness: "deferred" as const } : {}), ...(activationGate ? { activationGate } : {}), + ...(settings ? { settings } : {}), + ...(configOverrides ? { configOverrides } : {}), connectionCapabilities: connectionId => hostCapCache.get(connectionId), installProviderDefinitions, onProviderDefinitionsRemoved: removeProviderDefinitions, diff --git a/packages/coding-agent/src/sdk/host/session-runtime.ts b/packages/coding-agent/src/sdk/host/session-runtime.ts index 9a31b029c6..6965ac5192 100644 --- a/packages/coding-agent/src/sdk/host/session-runtime.ts +++ b/packages/coding-agent/src/sdk/host/session-runtime.ts @@ -3,10 +3,24 @@ import * as crypto from "node:crypto"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import { promisify } from "node:util"; +import { ThinkingLevel } from "@gajae-code/agent-core"; +import type { Api, Model } from "@gajae-code/ai/core"; import { logger } from "@gajae-code/utils"; import { isModelProfileProviderAvailable, projectModelProfileCatalog } from "../../config/model-profile-contract"; -import { isAuthenticated, kNoAuth } from "../../config/model-registry"; +import { type ModelProfileDefinition, resolveProfileBindings } from "../../config/model-profiles"; +import { resolveModelChainWithAuth } from "../../config/model-resolver"; +import { normalizeModelSelectorValue } from "../../config/model-selector-value"; +import { type Settings, validateSettingPatch } from "../../config/settings"; import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "../../extensibility/extensions"; +import { parseThinkingLevel } from "../../thinking"; +import { + collectAuthenticatedProfileProviders, + parseSyntheticModelId, + resolveSyntheticModelSelection, + SYNTHETIC_PROVIDER_ID, + syntheticModelInputError, + syntheticNamespaceCollision, +} from "../model-profile-model"; import { projectQ10Models } from "../models.js"; import { OPERATIONS } from "../protocol/operation-registry"; import { type ControlSurface, dispatchControl } from "./control"; @@ -52,6 +66,10 @@ export interface SessionSdkTransport { export interface SessionSdkRuntimeOptions extends Omit { transport: SessionSdkTransport; + /** Session settings; enables `config.patch` application on this runtime. */ + settings?: Settings; + /** Mutable shadow of patched config values merged into query readback. */ + configOverrides?: Map; } /** @@ -204,6 +222,10 @@ export interface CreateSdkSessionRuntimeOptions { token: string; }): SessionSdkTransport | Promise; onSdkRequest?: SessionSdkHostOptions["onRequest"]; + /** Session settings; enables `config.patch` application on this runtime. */ + settings?: Settings; + /** Mutable shadow of patched config values merged into query readback. */ + configOverrides?: Map; } function unavailable(operation: string): () => never { @@ -429,6 +451,8 @@ export interface SdkSurfaceFactoryOptions { getInstalledDefinitions?: (capability: string) => unknown | undefined; getLiveState?: () => { isStreaming: boolean; steeringQueueDepth: number; followupQueueDepth: number }; configOverrides?: ReadonlyMap; + /** Session settings; used for model-usage preferences in profile-limit resolution. */ + settings?: Settings; promptStatusLookup?: (selector: { commandId?: string; turnId?: string; clientRef?: string }) => unknown; skillStatusLookup?: (selector: { commandId?: string; turnId?: string; clientRef?: string }) => unknown; hostTools?: boolean | (() => boolean); @@ -451,6 +475,8 @@ function createQuerySurface( getInstalledDefinitions?: (capability: string) => unknown | undefined; getLiveState?: () => { isStreaming: boolean; steeringQueueDepth: number; followupQueueDepth: number }; configOverrides?: ReadonlyMap; + /** Session settings; used for model-usage preferences in profile-limit resolution. */ + settings?: Settings; promptStatusLookup?: (selector: { commandId?: string; turnId?: string; clientRef?: string }) => unknown; skillStatusLookup?: (selector: { commandId?: string; turnId?: string; clientRef?: string }) => unknown; hostTools?: boolean | (() => boolean); @@ -530,12 +556,135 @@ function createQuerySurface( typeof (ctx as Partial).getTodoState === "function" ? ctx.getTodoState() : [], getDiff, getUsage: () => ctx.sessionManager.getUsageStatistics(), - getModels: () => - projectQ10Models({ - models: ctx.modelRegistry.getAll(), - currentModel: ctx.model, - currentThinkingLevel: api.getThinkingLevel(), - }), + getModels: async () => { + const models = ctx.modelRegistry.getAll(); + const currentModel = ctx.model; + const currentThinkingLevel = api.getThinkingLevel(); + const activeProfile = + typeof ctx.getActiveModelProfile === "function" ? ctx.getActiveModelProfile() : undefined; + // A user-defined provider under the reserved logical namespace makes + // `gajae-code/*` ids ambiguous: selection is rejected, so Q10 must + // NOT advertise any rows from that namespace (neither the colliding + // provider's concrete models nor synthetic profiles). The collided + // provider's rows are filtered out of every degraded projection too, + // making the documented fail-closed behavior effective. + const collision = syntheticNamespaceCollision(models, ctx.modelRegistry.getConfiguredProviderIds?.() ?? []); + const concreteRows = collision ? models.filter(model => model.provider !== SYNTHETIC_PROVIDER_ID) : models; + // Degraded projection: concrete rows always (minus a collided + // gajae-code provider), plus a bounded synthetic current readback + // when a profile marker is active — unless the namespace is collided, + // in which case no synthetic row (including the active fallback) may + // appear because selection is rejected. + const degraded = () => + projectQ10Models( + activeProfile !== undefined && !collision + ? { + models: concreteRows, + currentModel, + currentThinkingLevel, + profiles: new Map(), + activeProfile, + } + : { models: concreteRows, currentModel, currentThinkingLevel }, + ); + let profiles: ReadonlyMap; + try { + const registryWithProfiles = ctx.modelRegistry as { + getModelProfiles?: () => ReadonlyMap; + }; + profiles = + typeof registryWithProfiles.getModelProfiles === "function" + ? registryWithProfiles.getModelProfiles() + : new Map(); + } catch { + // The profile registry is unreadable: keep the concrete catalog + // and the active marker readback; never fail the whole Q10 query. + return degraded(); + } + if (profiles.size === 0) return degraded(); + // An invalid models configuration must not advertise synthetic rows: + // the same registry error rejects selection, so Q10 fails closed to + // the concrete catalog (plus the active-marker readback). + if (ctx.modelRegistry.getError?.() !== undefined) return degraded(); + if (collision) return degraded(); + let authenticatedProviders: ReadonlySet; + try { + authenticatedProviders = await collectAuthenticatedProfileProviders(profiles, provider => + ctx.modelRegistry.getApiKeyForProvider(provider, id), + ); + } catch { + // Availability join failed: degrade only the synthetic facade, + // retain concrete rows and the active marker readback. + return degraded(); + } + // Resolve each profile's default model exactly like profile activation: + // walk the default mapping chain, rewrite alternative-group providers + // to their authenticated member, and use the same pattern-aware, + // managed-fallback-eligible resolver so Q10 reports the limits of the + // model the profile will actually activate (glob defaults such as + // `provider/gpt-*` and Cursor-managed-fallback skips included). + const resolvedDefaultModels = new Map>(); + const rewriteSelectorProvider = (selector: string, profile: ModelProfileDefinition): string => { + const slash = selector.indexOf("/"); + if (slash < 0) return selector; + const provider = selector.slice(0, slash); + if (authenticatedProviders.has(provider)) return selector; + const group = (profile.alternativeProviderGroups ?? []).find(candidates => candidates.includes(provider)); + if (!group) return selector; + const replacement = group.find(candidate => authenticatedProviders.has(candidate)); + return replacement ? replacement + selector.slice(slash) : selector; + }; + await Promise.all( + [...profiles.entries()].map(async ([name, profile]) => { + try { + const defaultSelector = resolveProfileBindings(profile).defaultSelector; + if (defaultSelector === undefined) return; // role-only profile + const selectors = normalizeModelSelectorValue(defaultSelector).map(selector => + rewriteSelectorProvider(selector, profile), + ); + const resolution = await resolveModelChainWithAuth( + selectors, + { + ...ctx.modelRegistry, + getAvailable: () => ctx.modelRegistry.getAll(), + getApiKey: (model: Model, sessionId?: string) => + ctx.modelRegistry.getApiKeyForProvider(model.provider, sessionId, model.baseUrl), + }, + options.settings, + id, + { managedFallback: true }, + ); + if (resolution.model) resolvedDefaultModels.set(name, resolution.model); + } catch { + // A provider whose credential state cannot be read must not + // fail the whole Q10 query: skip this profile's metadata + // resolution and degrade only its synthetic row. + } + }), + ); + const availableProfileIds = new Set(); + for (const [name, profile] of profiles) { + if (!isModelProfileProviderAvailable(profile, authenticatedProviders)) continue; + // A profile with a default mapping is selectable only when its + // default chain actually resolves to an authenticated model: + // activation rejects unresolvable defaults even when the + // required providers are authenticated. Role-only profiles + // (no default) remain selectable. + if (profile.modelMapping.default !== undefined && !resolvedDefaultModels.has(name)) continue; + availableProfileIds.add(name); + } + const resolveProfileDefaultModel = (profile: ModelProfileDefinition) => + resolvedDefaultModels.get(profile.name); + return projectQ10Models({ + models, + currentModel, + currentThinkingLevel, + profiles, + availableProfileIds, + activeProfile, + resolveProfileDefaultModel, + }); + }, getSkillState: () => ctx.getSkillState(), getGates: () => { const workflowGate = ctx.workflowGate; @@ -575,23 +724,15 @@ function createQuerySurface( (options.promptStatusLookup ?? (value => reconciliation.lookup("prompt", value)))(selector), getSkillInvokeStatus: (selector: { commandId?: string; turnId?: string; clientRef?: string }) => (options.skillStatusLookup ?? (value => reconciliation.lookup("skill", value)))(selector), - getModelProfiles: () => { + getModelProfiles: async () => { const profiles = ctx.modelRegistry.getModelProfiles(); - const providers = new Set([...profiles.values()].flatMap(profile => profile.requiredProviders)); - const authenticatedProviders = new Set(); - return Promise.all( - [...providers].map(async provider => { - try { - const credential = await ctx.modelRegistry.getApiKeyForProvider(provider, id); - if (credential === kNoAuth || isAuthenticated(credential)) authenticatedProviders.add(provider); - } catch {} - }), - ).then(() => { - return projectModelProfileCatalog(profiles, ctx.modelRegistry.getError()).map(item => ({ - ...item, - available: isModelProfileProviderAvailable(profiles.get(item.id)!, authenticatedProviders), - })) as unknown[]; - }); + const authenticatedProviders = await collectAuthenticatedProfileProviders(profiles, provider => + ctx.modelRegistry.getApiKeyForProvider(provider, id), + ); + return projectModelProfileCatalog(profiles, ctx.modelRegistry.getError()).map(item => ({ + ...item, + available: isModelProfileProviderAvailable(profiles.get(item.id)!, authenticatedProviders), + })) as unknown[]; }, installedQueries: policy.installedQueries, }; @@ -619,6 +760,7 @@ export function createSdkSurfaceFactory( getInstalledDefinitions: options.getInstalledDefinitions, getLiveState: options.getLiveState, configOverrides: options.configOverrides, + settings: options.settings, promptStatusLookup: options.promptStatusLookup, skillStatusLookup: options.skillStatusLookup, hostTools: options.hostTools, @@ -630,12 +772,70 @@ export function createSdkSurfaceFactory( }; } +function captureConfigOverridesShadow(settings: Settings, configOverrides: Map): Map { + const before = new Map(); + for (const key of configOverrides.keys()) { + try { + before.set(key, settings.get(key as never)); + } catch { + before.set(key, undefined); + } + } + return before; +} + +function reconcileConfigOverridesShadow( + settings: Settings, + configOverrides: Map, + before: ReadonlyMap, +): void { + for (const [key, prior] of before) { + let current: unknown; + try { + current = settings.get(key as never); + } catch { + current = undefined; + } + if (!deepStructuralEqual(current, prior)) configOverrides.delete(key); + } +} + +function deepStructuralEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true; + if (Array.isArray(left) && Array.isArray(right)) + return left.length === right.length && left.every((value, index) => deepStructuralEqual(value, right[index])); + if (left === null || right === null || typeof left !== "object" || typeof right !== "object") return false; + const leftRecord = left as Record; + const rightRecord = right as Record; + const leftKeys = Object.keys(leftRecord); + const rightKeys = Object.keys(rightRecord); + return ( + leftKeys.length === rightKeys.length && + leftKeys.every(key => deepStructuralEqual(leftRecord[key], rightRecord[key])) + ); +} + +/** True when a patch contains any secret-shaped key, recursively. */ +function containsSecretConfigKey(value: unknown, seen = new Set()): boolean { + if (!value || typeof value !== "object") return false; + if (seen.has(value)) return false; + seen.add(value); + if (Array.isArray(value)) return value.some(item => containsSecretConfigKey(item, seen)); + return Object.entries(value as Record).some( + ([key, nested]) => + /(?:token|secret|password|api[_-]?key|credential|authorization)/i.test(key) || + containsSecretConfigKey(nested, seen), + ); +} function createControlSurface( ctx: ExtensionContext, api: ExtensionAPI, reconciliation: InvocationReconciliation, onAccepted: (kind: InvocationKind, correlation: InvocationCorrelation) => void, policy?: SdkSurfacePolicy, + settings?: Settings, + configOverrides?: Map, + configRevision: { current: number } = { current: 0 }, ): ControlSurface { const surfacePolicy = policy ?? createSdkSurfacePolicyForContext(ctx, hasSdkWorkflowGateCapability(ctx.workflowGate)); @@ -650,6 +850,45 @@ function createControlSurface( if (!model) throw Object.assign(new Error(`Model ${id} was not found.`), { code: "invalid_input" }); return model; }; + /** + * Route a synthetic `gajae-code/` model selection into the + * session-scoped activation transaction. ACP model selection never writes a + * global profile default; persistence remains an explicit TUI choice. Only + * an absent or `off` thinking level is forwarded (synthetic rows advertise + * `validLevels: ["off"]`); any other level is rejected before admission. + * A user-defined provider under the reserved namespace fails closed rather + * than being shadowed. With a thinking level the typed host surface returns + * the pinned `DefaultModelSelectionResult`-shaped result. + */ + const setSyntheticModel = async (id: string, requestedThinkingLevel: unknown) => { + const hasLevel = requestedThinkingLevel !== undefined; + const thinkingLevel = + typeof requestedThinkingLevel === "string" ? parseThinkingLevel(requestedThinkingLevel) : undefined; + if ( + hasLevel && + (!thinkingLevel || thinkingLevel === ThinkingLevel.Inherit || thinkingLevel !== ThinkingLevel.Off) + ) + throw syntheticModelInputError('model.set thinkingLevel for a synthetic profile must be "off".'); + const profiles = ctx.modelRegistry.getModelProfiles(); + const resolved = resolveSyntheticModelSelection(id, profiles, ctx.modelRegistry.getError?.()); + if (syntheticNamespaceCollision(ctx.modelRegistry.getAll(), ctx.modelRegistry.getConfiguredProviderIds?.() ?? [])) + throw syntheticModelInputError( + `The ${SYNTHETIC_PROVIDER_ID} namespace is reserved; synthetic preset selection is disabled while a provider of the same name is configured.`, + ); + const setDefaultModelProfile = ctx.setDefaultModelProfile; + if (!setDefaultModelProfile) return unavailable("model.set")(); + await setDefaultModelProfile(resolved.canonicalName, { + persistDefault: false, + ...(hasLevel ? { thinkingLevelOverride: ThinkingLevel.Off } : {}), + }); + return hasLevel + ? { + provider: SYNTHETIC_PROVIDER_ID, + modelId: resolved.canonicalName, + thinkingLevel: ThinkingLevel.Off, + } + : { changed: true }; + }; const newCorrelation = () => ({ commandId: crypto.randomUUID(), turnId: crypto.randomUUID() }); const normalizeClientRef = (clientRef: string | undefined): string | undefined => { if (clientRef === undefined) return undefined; @@ -790,10 +1029,27 @@ function createControlSurface( ctx.operateGoal ? ctx.operateGoal(op as never, objective) : unavailable("mode.goal.operate")(), replaceTodo: items => typed("todo.replace", { items }), setModel: async (id, thinkingLevel) => { - const changed = await api.setModelTemporaryForControl(resolveModel(id)); - if (!changed) throw Object.assign(new Error("Model unavailable for this session."), { code: "unavailable" }); - if (thinkingLevel !== undefined) api.setThinkingLevel(thinkingLevel as never); - return { changed: true }; + if (parseSyntheticModelId(id) !== undefined) return setSyntheticModel(id, thinkingLevel); + // Serialize the concrete selection (and the Q13 shadow capture/reconcile) + // against config.patch through the session admission boundary so a + // concurrent patch cannot race the snapshot. + const run = async () => { + const shadowBefore = + settings && configOverrides ? captureConfigOverridesShadow(settings, configOverrides) : undefined; + const changed = await api.setModelTemporaryForControl( + resolveModel(id), + undefined, + thinkingLevel as ThinkingLevel | undefined, + ); + if (!changed) + throw Object.assign(new Error("Model unavailable for this session."), { code: "unavailable" }); + if (settings && configOverrides && shadowBefore) + reconcileConfigOverridesShadow(settings, configOverrides, shadowBefore); + return { changed: true }; + }; + return typeof (ctx as Partial).withSdkControlMutation === "function" + ? ctx.withSdkControlMutation!(run) + : run(); }, setModelProfile: id => (ctx.setModelProfile ? ctx.setModelProfile(id) : unavailable("model.profile.set")()), cycleModel: () => (ctx.cycleModel ? ctx.cycleModel() : unavailable("model.cycle")()), @@ -824,7 +1080,38 @@ function createControlSurface( renameSession: name => typed("session.rename", { name }), handoffSession: target => typed("session.handoff", { target }), exportHtml: () => typed("session.export_html"), - patchConfig: patch => typed("config.patch", { patch }), + patchConfig: patch => { + if (!patch || typeof patch !== "object" || Array.isArray(patch)) + throw Object.assign(new Error("config.patch requires an object."), { code: "invalid_input" }); + if (containsSecretConfigKey(patch)) + throw Object.assign(new Error("config.patch rejects secret fields at the SDK host."), { + code: "invalid_input", + }); + const patchIssues = validateSettingPatch(patch as Record); + if (patchIssues.length > 0) { + const detail = patchIssues.map(issue => `${issue.path} (${issue.detail})`).join("; "); + throw Object.assign(new Error(`config.patch rejects invalid settings: ${detail}`), { + code: "invalid_input", + }); + } + if (!settings) return unavailable("config.patch")(); + const applyPatch = async () => { + const entries = Object.entries(patch as Record); + for (const [key, value] of entries) settings.set(key as never, value as never); + if (configOverrides) for (const [key, value] of entries) configOverrides.set(key, value); + configRevision.current += 1; + return { patched: entries.map(([key]) => key), revision: String(configRevision.current) }; + }; + // Serialize config mutations against synthetic profile activation and + // default-model selection so an interleaved patch can never be lost or + // clobbered by an activation rollback. The patch itself authoritatively + // updates the shadow, so it must NOT be wrapped in the shadow refresh + // (that would delete the entry it just wrote on the second patch). + if (typeof (ctx as Partial).withSdkControlMutation === "function") { + return ctx.withSdkControlMutation!(applyPatch); + } + return applyPatch(); + }, reloadRuntime: components => typed("runtime.reload", { components }), login: provider => typed("auth.login", { provider }), registerHostTools: defs => typed("host_tools.register", { defs }), @@ -855,6 +1142,7 @@ function createControlSurface( retryNow: () => typed("retry.now"), backgroundBash: () => typed("bash.background"), installedOperations: surfacePolicy.installedControls, + revisionProvider: resource => (resource === "config" ? String(configRevision.current) : undefined), }; } @@ -935,6 +1223,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre const reconciliation = createInvocationReconciliation({ stateRoot, sessionId }); await reconciliation.hydrate(); const pending: Array<{ kind: InvocationKind; correlation: InvocationCorrelation }> = []; + const configRevision = { current: 0 }; const surfaceFactory = createSdkSurfaceFactory({ ctx, id: sessionId, @@ -942,6 +1231,8 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre reconciliation, promptStatusLookup: selector => reconciliation.lookup("prompt", selector), skillStatusLookup: selector => reconciliation.lookup("skill", selector), + configOverrides: options.configOverrides, + settings: options.settings, }); const queryHandlers = new QueryHandlers(surfaceFactory.query, sessionId, revisions, cursors); const controlSurface = createControlSurface( @@ -952,6 +1243,9 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre pending.push({ kind, correlation }); }, surfaceFactory.policy, + options.settings, + options.configOverrides, + configRevision, ); let runtime: SessionSdkSessionRuntime; const installProviderDefinitions = (capability: string, definitions: unknown): void => { diff --git a/packages/coding-agent/src/sdk/model-profile-model.ts b/packages/coding-agent/src/sdk/model-profile-model.ts new file mode 100644 index 0000000000..4fd07e21c5 --- /dev/null +++ b/packages/coding-agent/src/sdk/model-profile-model.ts @@ -0,0 +1,131 @@ +import { UNK_CONTEXT_WINDOW, UNK_MAX_TOKENS } from "@gajae-code/ai/core"; +import { + ModelProfileRegistryError, + UnknownModelProfileError, + validateModelProfileName, +} from "../config/model-profile-contract"; +import type { ModelProfileDefinition } from "../config/model-profiles"; +import { isAuthenticated, kNoAuth } from "../config/model-registry"; + +/** + * Reserved logical provider namespace for the preset-as-model facade. + * + * `gajae-code/` is not a real API provider: rows under this namespace + * exist only in the SDK Q10 projection and `model.set` selection routes to a + * session-scoped model-profile activation transaction. The namespace is + * reserved so a user-defined `models.yml` provider of the same name cannot + * silently shadow the facade (see {@link syntheticNamespaceCollision}). + */ +export const SYNTHETIC_PROVIDER_ID = "gajae-code"; + +/** + * Display metadata used for synthetic rows whose profile default model cannot + * be resolved to a registered model. Mirrors the shared unknown-model + * constants from `@gajae-code/ai`; the real model's window is authoritative. + */ +export const SYNTHETIC_UNKNOWN_CONTEXT_WINDOW = UNK_CONTEXT_WINDOW; +export const SYNTHETIC_UNKNOWN_MAX_TOKENS = UNK_MAX_TOKENS; + +export function isSyntheticModelId(modelId: string): boolean { + return modelId.startsWith(`${SYNTHETIC_PROVIDER_ID}/`); +} + +export function buildSyntheticModelId(profileName: string): string { + return `${SYNTHETIC_PROVIDER_ID}/${profileName}`; +} + +/** + * Parse a synthetic model id losslessly. Only the first namespace slash is + * consumed; the full suffix (which may itself contain slashes or punctuation, + * matching the configured-profile contract) is the profile id. + */ +export function parseSyntheticModelId(modelId: string): { profileName: string } | undefined { + if (!isSyntheticModelId(modelId)) return undefined; + const suffix = modelId.slice(SYNTHETIC_PROVIDER_ID.length + 1); + return suffix.length > 0 ? { profileName: suffix } : undefined; +} + +/** A bounded SDK `invalid_input`-coded error for the synthetic selection branch. */ +export function syntheticModelInputError(message: string): Error { + return Object.assign(new Error(message), { code: "invalid_input" }); +} + +export interface ResolvedSyntheticModelSelection { + /** The raw profile suffix after the first namespace slash. */ + profileName: string; + /** The canonical profile id (legacy aliases resolved, errors fail closed). */ + canonicalName: string; +} + +/** + * Canonicalize a synthetic model id against the merged profile registry. + * Profile/registry failures are converted to the SDK's `invalid_input` code so + * the ACP adapter's existing `invalid_input -> invalidParams` mapping applies. + */ +export function resolveSyntheticModelSelection( + modelId: string, + profiles: ReadonlyMap, + registryError?: unknown, +): ResolvedSyntheticModelSelection { + const parsed = parseSyntheticModelId(modelId); + if (!parsed) throw syntheticModelInputError(`Model ${modelId} is not a valid synthetic profile selection.`); + const { profileName } = parsed; + if (registryError !== undefined) + throw syntheticModelInputError("The model profile registry is unavailable; fix models.yml before retrying."); + let canonicalName: string; + try { + canonicalName = validateModelProfileName(profileName, profiles, registryError); + } catch (error) { + if (error instanceof UnknownModelProfileError || error instanceof ModelProfileRegistryError) + throw syntheticModelInputError(error.message); + throw error; + } + return { profileName, canonicalName }; +} + +/** + * True when a real registry entry shadows the reserved logical namespace. + * Callers must omit synthetic rows (and reject synthetic selection) in that + * case instead of silently shadowing or misrouting the user's provider. + */ +export function syntheticNamespaceCollision( + models: readonly { provider: string }[], + configuredProviderIds: readonly string[] = [], +): boolean { + return ( + models.some(model => model.provider === SYNTHETIC_PROVIDER_ID) || + configuredProviderIds.includes(SYNTHETIC_PROVIDER_ID) + ); +} + +/** + * Resolve the set of providers with usable credentials across every profile's + * strict requirements and alternative groups. This is the single availability + * derivation shared by the Q10 synthetic facade and the Q27 profile catalog so + * both surfaces agree; `kNoAuth` counts as available and per-provider lookup + * failures simply exclude that provider. + */ +export async function collectAuthenticatedProfileProviders( + profiles: ReadonlyMap, + getApiKeyForProvider: (provider: string) => Promise, +): Promise> { + const providers = new Set(); + for (const profile of profiles.values()) { + for (const provider of profile.requiredProviders) providers.add(provider); + for (const group of profile.alternativeProviderGroups ?? []) { + for (const provider of group) providers.add(provider); + } + } + const authenticatedProviders = new Set(); + await Promise.all( + [...providers].map(async provider => { + try { + const credential = await getApiKeyForProvider(provider); + if (credential === kNoAuth || isAuthenticated(credential)) authenticatedProviders.add(provider); + } catch { + // A provider whose credential state cannot be read is not currently configurable. + } + }), + ); + return authenticatedProviders; +} diff --git a/packages/coding-agent/src/sdk/models.ts b/packages/coding-agent/src/sdk/models.ts index b1f55a13e9..8663e73ddc 100644 --- a/packages/coding-agent/src/sdk/models.ts +++ b/packages/coding-agent/src/sdk/models.ts @@ -7,7 +7,11 @@ import { THINKING_CONTROL_MODES, THINKING_EFFORTS, type ThinkingControlMode, + UNK_CONTEXT_WINDOW, + UNK_MAX_TOKENS, } from "@gajae-code/ai/core"; +import { formatModelProfileDisplayLabel, type ModelProfileDefinition } from "../config/model-profiles"; +import { SYNTHETIC_PROVIDER_ID } from "./model-profile-model"; export type Q10ThinkingEffort = Effort; export type Q10SettableThinkingLevel = typeof ThinkingLevel.Off | Q10ThinkingEffort; @@ -71,6 +75,19 @@ export interface Q10ModelProjectionInput { currentModel?: Model; currentThinkingLevel?: Q10CurrentThinkingLevel; resolveSupportedEfforts?: (model: Model) => readonly Effort[]; + /** + * Logical model-profile facade inputs. When supplied, deterministic, + * availability-filtered synthetic rows (`gajae-code/`) are + * appended after the real rows; the underlying `Model` registry entries + * are never touched. + */ + profiles?: ReadonlyMap; + /** Profile ids whose providers are authenticated and therefore selectable. */ + availableProfileIds?: ReadonlySet; + /** Active in-session profile marker; its synthetic row is the logical current. */ + activeProfile?: string; + /** Resolve a profile's default concrete model for display metadata. */ + resolveProfileDefaultModel?: (profile: ModelProfileDefinition) => Model | undefined; } /** @@ -78,8 +95,10 @@ export interface Q10ModelProjectionInput { * transport, credentials, pricing, or other registry internals. */ export function projectQ10Models(input: Q10ModelProjectionInput): Q10Model[] { - return input.models.map(model => { - const current = input.currentModel?.provider === model.provider && input.currentModel.id === model.id; + const profileActive = input.activeProfile !== undefined; + const concrete = input.models.map(model => { + const current = + !profileActive && input.currentModel?.provider === model.provider && input.currentModel.id === model.id; const base: Q10Model = { provider: model.provider, id: model.id, @@ -100,6 +119,62 @@ export function projectQ10Models(input: Q10ModelProjectionInput): Q10Model[] { thinking: projectThinking(model, input.resolveSupportedEfforts ?? getSupportedEfforts), }; }); + const synthetic = projectSyntheticQ10Models(input); + return synthetic.length === 0 ? concrete : [...concrete, ...synthetic]; +} + +/** + * Project model profiles as logical synthetic model rows. Only profiles that + * pass the authenticated-provider availability join (plus the active profile, + * which remains visible as the current logical readback even when its + * providers are no longer authenticated or the profile is absent from the + * registry map) are included. An absent availability set fails closed: without + * a verified join, non-current profiles are never advertised as selectable. + * Rows are appended sorted by profile id so placement across paginated Q10 + * responses stays deterministic. + */ +function projectSyntheticQ10Models(input: Q10ModelProjectionInput): Q10Model[] { + if (!input.profiles) return []; + const rows: Q10Model[] = []; + let activeRowEmitted = false; + for (const [name, profile] of [...input.profiles.entries()].sort(([left], [right]) => left.localeCompare(right))) { + const isActive = input.activeProfile === name; + if (isActive) activeRowEmitted = true; + // Fail closed on an unknown availability join: never advertise a + // non-current profile as selectable without verified credentials. + if (!isActive && input.availableProfileIds === undefined) continue; + if (!isActive && input.availableProfileIds !== undefined && !input.availableProfileIds.has(name)) continue; + const defaultModel = input.resolveProfileDefaultModel?.(profile); + rows.push({ + provider: SYNTHETIC_PROVIDER_ID, + id: name, + name: formatModelProfileDisplayLabel(profile), + contextWindow: defaultModel?.contextWindow ?? UNK_CONTEXT_WINDOW, + maxTokens: defaultModel?.maxTokens ?? UNK_MAX_TOKENS, + reasoning: false, + thinking: { validLevels: [ThinkingLevel.Off] }, + current: isActive, + ...(isActive ? { currentThinkingLevel: ThinkingLevel.Inherit } : {}), + }); + } + // Bounded current readback for an active marker whose profile is absent + // from the registry map (or whose availability is unknown): exactly one + // current row must exist while the marker is set, so concrete current + // suppression stays truthful. + if (input.activeProfile !== undefined && !activeRowEmitted) { + rows.push({ + provider: SYNTHETIC_PROVIDER_ID, + id: input.activeProfile, + name: input.activeProfile, + contextWindow: UNK_CONTEXT_WINDOW, + maxTokens: UNK_MAX_TOKENS, + reasoning: false, + thinking: { validLevels: [ThinkingLevel.Off] }, + current: true, + currentThinkingLevel: ThinkingLevel.Inherit, + }); + } + return rows; } function projectThinking( diff --git a/packages/coding-agent/src/sdk/protocol/operation-inventory.generated.json b/packages/coding-agent/src/sdk/protocol/operation-inventory.generated.json index 7b7f294e8b..ec1945196c 100644 --- a/packages/coding-agent/src/sdk/protocol/operation-inventory.generated.json +++ b/packages/coding-agent/src/sdk/protocol/operation-inventory.generated.json @@ -3841,6 +3841,39 @@ "testIds": "not_applicable" } }, + { + "sourceId": "agent_session:noteProfileInstalledOverrides", + "sourceFile": "packages/coding-agent/src/session/agent-session.ts", + "sourceKind": "agent_session", + "decision": "exclude", + "rationale": "internal session-scoped profile lifecycle plumbing behind the reviewed model.set seam, not an independent public SDK operation", + "exclusionMetadata": { + "adapterMappings": "not_applicable", + "testIds": "not_applicable" + } + }, + { + "sourceId": "agent_session:clearProfileInstalledOverrides", + "sourceFile": "packages/coding-agent/src/session/agent-session.ts", + "sourceKind": "agent_session", + "decision": "exclude", + "rationale": "internal session-scoped profile lifecycle plumbing behind the reviewed model.set seam, not an independent public SDK operation", + "exclusionMetadata": { + "adapterMappings": "not_applicable", + "testIds": "not_applicable" + } + }, + { + "sourceId": "agent_session:getProfileInstalledOverrideKeys", + "sourceFile": "packages/coding-agent/src/session/agent-session.ts", + "sourceKind": "agent_session", + "decision": "exclude", + "rationale": "internal session-scoped profile lifecycle accessor behind the reviewed model.set seam, not an independent public SDK operation", + "exclusionMetadata": { + "adapterMappings": "not_applicable", + "testIds": "not_applicable" + } + }, { "sourceId": "agent_session:activateModelProfileForControl", "sourceFile": "packages/coding-agent/src/session/agent-session.ts", @@ -3859,6 +3892,50 @@ "packages/coding-agent/test/sdk-operation-inventory.test.ts" ] }, + { + "sourceId": "agent_session:materializeActiveDefaultModelProfileAssignment", + "sourceFile": "packages/coding-agent/src/session/agent-session.ts", + "sourceKind": "agent_session", + "decision": "exclude", + "rationale": "internal extension selection materialization seam, not a user-facing SDK control seam", + "exclusionMetadata": { + "adapterMappings": "not_applicable", + "testIds": "not_applicable" + } + }, + { + "sourceId": "agent_session:setDefaultModelProfileForControl", + "sourceFile": "packages/coding-agent/src/session/agent-session.ts", + "sourceKind": "agent_session", + "decision": "exclude", + "rationale": "internal control wrapper behind the reviewed model.set seam (session-scoped synthetic gajae-code selection), not an independent public SDK operation", + "exclusionMetadata": { + "adapterMappings": "not_applicable", + "testIds": "not_applicable" + } + }, + { + "sourceId": "agent_session:clearSessionOnlyModelProfileState", + "sourceFile": "packages/coding-agent/src/session/agent-session.ts", + "sourceKind": "agent_session", + "decision": "exclude", + "rationale": "internal session-scoped profile lifecycle plumbing behind the reviewed model.set seam, not an independent public SDK operation", + "exclusionMetadata": { + "adapterMappings": "not_applicable", + "testIds": "not_applicable" + } + }, + { + "sourceId": "agent_session:withSdkControlMutation", + "sourceFile": "packages/coding-agent/src/session/agent-session.ts", + "sourceKind": "agent_session", + "decision": "exclude", + "rationale": "internal session admission wrapper for the reviewed config.patch seam, not an independent public SDK operation", + "exclusionMetadata": { + "adapterMappings": "not_applicable", + "testIds": "not_applicable" + } + }, { "sourceId": "agent_session:getConfiguredModelChain", "sourceFile": "packages/coding-agent/src/session/agent-session.ts", diff --git a/packages/coding-agent/src/sdk/session.ts b/packages/coding-agent/src/sdk/session.ts index b43117ab1d..3da521ae7f 100644 --- a/packages/coding-agent/src/sdk/session.ts +++ b/packages/coding-agent/src/sdk/session.ts @@ -2110,6 +2110,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const { createSdkWebSocketTransport } = await import("../sdk/host/websocket-transport"); createSdkSessionRuntimeExtension(api, { createTransport: input => createSdkWebSocketTransport(input), + settings, + configOverrides: new Map(), }); } } catch (error) { diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 61ed876bb2..cb48980a26 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -190,7 +190,12 @@ import { type AsyncJob, type AsyncJobDeliveryState, AsyncJobManager, type OwnerS import { reset as resetCapabilities } from "../capability"; import type { Rule } from "../capability/rule"; import type { CasReceipt } from "../config/atomic-yaml-patch"; -import { activateModelProfile } from "../config/model-profile-activation"; +import { activateModelProfile, materializeActiveModelProfileAssignment } from "../config/model-profile-activation"; +import { + ModelProfileRegistryError, + UnknownModelProfileError, + validateModelProfileName, +} from "../config/model-profile-contract"; import { GJC_MODEL_ASSIGNMENT_TARGETS, isAuthenticated, @@ -209,7 +214,7 @@ import { resolveModelRoleValue, type ScopedModelSelection, } from "../config/model-resolver"; -import { normalizeModelSelectorValue } from "../config/model-selector-value"; +import { type ModelSelectorValue, normalizeModelSelectorValue } from "../config/model-selector-value"; import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates"; import type { Settings, SkillsSettings } from "../config/settings"; import { onAppendOnlyModeChanged } from "../config/settings"; @@ -325,6 +330,7 @@ import type { NetworkPrewarmRuntime } from "../runtime/network-prewarm-service"; import type { WorkspaceTreeRuntime } from "../runtime/workspace-tree-service"; import { MCPManager } from "../runtime-mcp/manager"; import type { NotificationSessionController } from "../sdk/bus/session-control"; +import { buildSyntheticModelId, syntheticNamespaceCollision } from "../sdk/model-profile-model"; import type { SecretObfuscator } from "../secrets/obfuscator"; import { formatNoCredentialOnboardingError, formatNoModelOnboardingError } from "../setup/model-onboarding-guidance"; import { @@ -1813,6 +1819,9 @@ export class AgentSession { #scopedModels: ScopedModelSelection[]; #thinkingLevel: ThinkingLevel | undefined; #activeModelProfile: string | undefined; + #activeProfileInstalledRoles = new Map(); + #activeProfileInstalledAgentOverrides = new Map(); + #preProfileModel: Model | undefined; #sessionAdmissionQueue: SessionAdmissionEntry[] = []; #activeSessionAdmission: SessionAdmissionEntry | undefined; #sessionAdmissionClosed = false; @@ -7733,10 +7742,22 @@ export class AgentSession { /** Live SDK configuration values exposed through the session query surface. */ getSdkConfigItems(): Record { const model = this.model; - const modelPreset = this.getActiveModelProfile() ?? this.settings.get("modelProfile.default"); + const activeProfile = this.getActiveModelProfile(); + const syntheticNamespaceAvailable = !syntheticNamespaceCollision( + this.#modelRegistry.getAll(), + this.#modelRegistry.getConfiguredProviderIds(), + ); + const modelPreset = activeProfile ?? this.settings.get("modelProfile.default"); return { mode: this.#planModeState?.enabled ? "plan" : "default", - ...(model ? { model: `${model.provider}/${model.id}` } : {}), + ...(model + ? { + model: + activeProfile && syntheticNamespaceAvailable + ? buildSyntheticModelId(activeProfile) + : `${model.provider}/${model.id}`, + } + : {}), ...(modelPreset ? { modelPreset } : {}), thinking: this.#thinkingLevel ?? "off", steeringMode: this.steeringMode, @@ -9101,6 +9122,9 @@ export class AgentSession { }, cycleModel: () => this.cycleModel(), setModelProfile: name => this.activateModelProfileForControl(name), + setDefaultModelProfile: (name, options) => this.setDefaultModelProfileForControl(name, options), + getActiveModelProfile: () => this.getActiveModelProfile(), + withSdkControlMutation: body => this.withSdkControlMutation(body), cycleThinkingLevel: () => this.cycleThinkingLevel(), setQueueMode: (kind, mode) => { if (kind === "steering" && (mode === "all" || mode === "one-at-a-time")) { @@ -9129,6 +9153,8 @@ export class AgentSession { sdkBindings: () => [ "cycleModel", "setModelProfile", + "setDefaultModelProfile", + "getActiveModelProfile", "cycleThinkingLevel", "setQueueMode", "getSkillState", @@ -10548,6 +10574,25 @@ export class AgentSession { nextDiscoverySessionToolNames: string[] | undefined, previousSessionFile: string | undefined, ): Promise { + // The successor session must not inherit the predecessor's profile marker + // or its runtime role overrides; a durable `modelProfile.default` is + // reapplied by the startup policy on a fresh launch instead. + const droppingSessionOnlyProfile = + this.getActiveModelProfile() !== undefined && + this.settings.get("modelProfile.default") !== this.getActiveModelProfile(); + const preProfileModel = this.#preProfileModel; + this.#resetSessionScopedModelProfileState(); + // A dropped session-only profile must not leak its concrete model into + // the successor: restore the configured global default model before it is + // recorded as the new session's model. + if (droppingSessionOnlyProfile) { + // A session-only profile has no durable default, so its pre-activation + // model is the only correct restore target. + const restoredDefault = this.resolveConfiguredDefaultModel() ?? preProfileModel; + if (restoredDefault && (!this.model || !modelsAreEqual(this.model, restoredDefault))) { + this.#setModelAuthoritatively(restoredDefault, "restore"); + } + } this.#clearConstructorToolSelectionAuthority(); const inheritedThinkingLevel = resolveThinkingLevelForModel(this.model, this.#getInheritedThinkingLevel()); this.#thinkingLevelMutationRevision++; @@ -10769,7 +10814,116 @@ export class AgentSession { return this.#activeModelProfile; } - /** Activate a complete model profile through a nonvisual session control. */ + /** + * Drop the in-session profile marker and the runtime settings overrides a + * session-only profile activation installed. Session transitions + * (new/switch/resume) reuse the same `AgentSession`; without this reset, Q10 + * would report the predecessor's synthetic profile as current in the + * successor session and the profile's role overrides would leak into its + * turns. + * + * A durable profile (the active marker matching the persisted + * `modelProfile.default`) stays configured for the successor: the startup + * policy reapplies it on the next launch, so its marker and runtime role + * overrides must survive the in-process transition. + * + * Only override keys a profile activation actually installed are removed: + * configured `modelBindings` (also installed into these two override slots + * once at startup) are not profile-owned and must survive the transition. + */ + #resetSessionScopedModelProfileState(): void { + const persistedProfile = this.settings.get("modelProfile.default"); + if (persistedProfile !== undefined && persistedProfile === this.getActiveModelProfile()) return; + const hadInstalledKeys = + this.#activeProfileInstalledRoles.size > 0 || this.#activeProfileInstalledAgentOverrides.size > 0; + if (hadInstalledKeys) { + const modelRoles = { ...this.settings.get("modelRoles") }; + const agentOverrides = { ...this.settings.get("task.agentModelOverrides") }; + for (const [role, baseline] of this.#activeProfileInstalledRoles) { + // The `default` role is rewritten durably by concrete picks and + // transitions; never shadow the newer durable value with a stale + // pre-profile override. + if (role === "default" || baseline === undefined) delete modelRoles[role]; + else modelRoles[role] = baseline; + } + for (const [role, baseline] of this.#activeProfileInstalledAgentOverrides) { + if (baseline === undefined) delete agentOverrides[role]; + else agentOverrides[role] = baseline; + } + this.settings.override("modelRoles", modelRoles); + this.settings.override("task.agentModelOverrides", agentOverrides); + this.#activeProfileInstalledRoles.clear(); + this.#activeProfileInstalledAgentOverrides.clear(); + } + // Configured modelBindings share these override slots with profile + // activations and must survive the drop even when the profile contributed + // no role keys; a profile-free transition leaves them untouched. + if (hadInstalledKeys || this.getActiveModelProfile() !== undefined) { + this.#modelRegistry.reapplyConfiguredModelBindings(this.settings); + } + // A dropped profile may have installed a fallback chain; clear it so + // retry/resume does not reconstruct the stale profile chain. + const defaultChain = getSessionContextForInternalRead(this.sessionManager).configuredModelChains.default; + if (defaultChain && defaultChain.identity !== undefined) { + this.setConfiguredModelChain("default", [], "user-selection"); + } + this.setActiveModelProfile(undefined); + this.#preProfileModel = undefined; + } + + /** + * Record which runtime override keys a profile activation installed together + * with their pre-profile effective baseline (durable/project value or a + * configured `modelBindings` value). First activation for a key wins, so a + * profile switch never captures the previous profile's roles as the + * baseline. The session-scoped reset restores exactly these keys. + */ + noteProfileInstalledOverrides( + modelRoles: readonly string[], + agentModelOverrides: readonly string[], + preProfileModel: Model | undefined, + ): void { + const bindings = this.#modelRegistry.getConfiguredModelBindings?.(); + // Captured by the caller before activation replaced the runtime model; reading + // `this.model` here would record the profile's own model. First activation wins, + // so a chain of session-only profiles still restores the original selection. + if (this.#preProfileModel === undefined) this.#preProfileModel = preProfileModel; + for (const role of modelRoles) { + if (this.#activeProfileInstalledRoles.has(role)) continue; + const bindingValue = bindings?.modelRoles?.[role]; + this.#activeProfileInstalledRoles.set( + role, + bindingValue ?? this.settings.getGlobal("modelRoles")?.[role as never], + ); + } + for (const role of agentModelOverrides) { + if (this.#activeProfileInstalledAgentOverrides.has(role)) continue; + const bindingValue = bindings?.agentModelOverrides?.[role]; + this.#activeProfileInstalledAgentOverrides.set( + role, + bindingValue ?? this.settings.getGlobal("task.agentModelOverrides")?.[role as never], + ); + } + } + + /** Drop the recorded profile-installed override keys (after materialization). */ + clearProfileInstalledOverrides(): void { + this.#activeProfileInstalledRoles.clear(); + this.#activeProfileInstalledAgentOverrides.clear(); + } + + /** Current profile-installed override keys, for deriving the activation base. */ + getProfileInstalledOverrideKeys(): { modelRoles: readonly string[]; agentModelOverrides: readonly string[] } { + return { + modelRoles: [...this.#activeProfileInstalledRoles.keys()], + agentModelOverrides: [...this.#activeProfileInstalledAgentOverrides.keys()], + }; + } + + /** + * Activate a complete model profile through a nonvisual session control. + * Session-scoped only: does not persist `modelProfile.default`. + */ async activateModelProfileForControl(profileName: string): Promise { await activateModelProfile({ session: this, @@ -10780,6 +10934,136 @@ export class AgentSession { return this.getActiveModelProfile() === profileName; } + /** + * Activate a model profile from a control surface. + * + * Control selections are session-scoped unless the caller explicitly opts + * into persistence. The transaction canonicalizes the profile, performs + * credential preflight, and serializes against other session admissions. + * Unknown/registry profile failures are surfaced as SDK `invalid_input` so + * the ACP adapter maps them to invalid params. + */ + /** Persist effective roles only when the active profile is a durable default. */ + materializeActiveDefaultModelProfileAssignment(model: Model): boolean { + // A merged read could let a project-scoped value authorize durable global writes. + const persistedProfile = this.settings.getGlobal("modelProfile.default"); + if (persistedProfile === undefined || persistedProfile !== this.getActiveModelProfile()) return false; + return materializeActiveModelProfileAssignment({ + session: this, + settings: this.settings, + role: "default", + selector: formatModelSelectorValue(`${model.provider}/${model.id}`, this.thinkingLevel), + }); + } + + async setDefaultModelProfileForControl( + profileName: string, + options?: { + persistDefault?: boolean; + thinkingLevelOverride?: ThinkingLevel; + onBeforeActivation?: () => void; + onAfterActivation?: () => void; + }, + ): Promise<{ changed: boolean; id: string }> { + const canonicalName = await this.#withSessionAdmission("selection", async () => { + // A model-picker action must not alter an in-flight generation: wait + // for the current run to settle inside the admission before applying + // the profile, matching `setDefaultModelSelection`. + await this.waitForIdle(); + const profiles = this.#modelRegistry.getModelProfiles(); + let canonical: string; + try { + canonical = validateModelProfileName(profileName, profiles, this.#modelRegistry.getError?.()); + } catch (error) { + if (error instanceof UnknownModelProfileError || error instanceof ModelProfileRegistryError) + throw Object.assign(new Error(error.message), { code: "invalid_input" }); + throw error; + } + const priorModel = this.model; + options?.onBeforeActivation?.(); + await activateModelProfile( + { + session: this, + modelRegistry: this.#modelRegistry, + settings: this.settings, + profileName: canonical, + }, + { + persistDefault: options?.persistDefault ?? false, + thinkingLevelOverride: options?.thinkingLevelOverride, + }, + ); + options?.onAfterActivation?.(); + // A role-only profile has no default model, so the activation never + // calls `setModelTemporary` — the only place that consumes the + // thinking override. Apply the override to the existing model so the + // typed synthetic result (e.g. `off`) is honest. + if (options?.thinkingLevelOverride !== undefined && this.model === priorModel) { + this.setThinkingLevel(options.thinkingLevelOverride); + } + return canonical; + }); + return { changed: this.getActiveModelProfile() === canonicalName, id: canonicalName }; + } + + /** + * Clear the active-profile marker after a successful concrete + * materialization that persists as the session default with a + * user-selection or startup-override cause. Internal temporary/fallback/ + * restore/rollback switches and the activation transaction itself (cause + * `profile-activation`) never clear the marker. + */ + #clearActiveModelProfileForConcreteDefault(cause: ModelChangeCause | undefined): void { + if (cause !== "user-selection" && cause !== "startup-override") return; + // A persisted default profile is replaced by materializing its effective + // assignments into the durable layer first. A session-only marker is + // dropped together with the runtime role overrides the profile activation + // installed, so the concrete default takes effect for every role without + // writing the profile's role mappings globally. + if (this.model && this.settings.get("modelProfile.default") !== undefined) { + if (this.materializeActiveDefaultModelProfileAssignment(this.model)) return; + } + // A persisted default that no longer matches the dropped session-only + // marker is superseded: the concrete selection is now the durable + // default, so the next launch must not reapply the stale profile. + const persistedProfile = this.settings.get("modelProfile.default"); + if (persistedProfile !== undefined && persistedProfile !== this.getActiveModelProfile()) { + this.settings.unset("modelProfile.default"); + this.settings.clearOverride("modelProfile.default"); + } + this.#resetSessionScopedModelProfileState(); + } + + /** + * Drop a session-only profile marker and the runtime role overrides its + * activation installed. Exposed for the extension `setModel` seam so a + * concrete pick clears session-only profiles without materializing them + * globally. A stale persisted default that no longer matches the dropped + * marker is superseded by the concrete selection and removed. + */ + clearSessionOnlyModelProfileState(): void { + const persistedProfile = this.settings.get("modelProfile.default"); + if (persistedProfile !== undefined && persistedProfile !== this.getActiveModelProfile()) { + this.settings.unset("modelProfile.default"); + this.settings.clearOverride("modelProfile.default"); + } + this.#resetSessionScopedModelProfileState(); + } + + /** + * Run a control-surface mutation inside the session admission boundary so + * SDK `config.patch` and other host mutations serialize against synthetic + * profile activation and default-model selection. + */ + async withSdkControlMutation(body: () => Promise): Promise { + return this.#withSessionAdmission("selection", async () => { + // A config mutation can change the active model-role assignment. Do not + // alter an in-flight turn after its prompt admission lease has released. + await this.waitForIdle(); + return await body(); + }); + } + /** Return the persisted configured fallback selectors for a model role. */ getConfiguredModelChain(role: string): readonly string[] | undefined { return getSessionContextForInternalRead(this.sessionManager).configuredModelChains[role]?.entries; @@ -10931,6 +11215,7 @@ export class AgentSession { // Apply explicit thinking level if given; otherwise prefer the model's // configured defaultLevel; otherwise re-clamp the current level. this.setThinkingLevel(thinkingLevel ?? model.thinking?.defaultLevel ?? this.thinkingLevel); + if (options?.persistAsSessionDefault === true) this.#clearActiveModelProfileForConcreteDefault(options?.cause); await this.#syncEditToolModeAfterModelChange(previousEditMode); } catch (error) { if (ownsScope) this.restoreTemporaryProviderSessionScope(scope); @@ -11035,10 +11320,14 @@ export class AgentSession { } /** Set a durable per-session model from a control surface without exposing credential errors. */ - async setModelTemporaryForControl(model: Model, expectedSessionId: string = this.sessionId): Promise { + async setModelTemporaryForControl( + model: Model, + expectedSessionId: string = this.sessionId, + thinkingLevel?: ThinkingLevel, + ): Promise { if (expectedSessionId !== this.sessionId) return false; try { - await this.setModelTemporary(model, undefined, { + await this.setModelTemporary(model, thinkingLevel, { persistAsSessionDefault: true, cause: "user-selection", }); @@ -11051,8 +11340,15 @@ export class AgentSession { async setDefaultModelSelection( model: Model, thinkingLevel: ThinkingLevel | undefined, + options?: { + /** Run inside the selection admission before the durable mutation. */ + onBeforeMutation?: () => void; + /** Run inside the selection admission after the durable mutation. */ + onAfterMutation?: () => void; + }, ): Promise { return this.#withSessionAdmission("selection", async () => { + options?.onBeforeMutation?.(); const expectedSessionId = this.sessionId; if (thinkingLevel === ThinkingLevel.Inherit) { @@ -11136,6 +11432,8 @@ export class AgentSession { }); } } + this.#clearActiveModelProfileForConcreteDefault("user-selection"); + options?.onAfterMutation?.(); return { provider: model.provider, modelId: model.id, thinkingLevel: effectiveLevel }; }); } @@ -11225,6 +11523,9 @@ export class AgentSession { if (next.explicitThinkingLevel && next.thinkingLevel !== undefined) { this.setThinkingLevel(next.thinkingLevel); } + // Materialize only after applying the selected explicit level so the + // durable selector matches the live cycle result after restart. + this.#clearActiveModelProfileForConcreteDefault("user-selection"); } return { model: next.model, thinkingLevel: this.thinkingLevel, role: next.role }; @@ -11265,9 +11566,10 @@ export class AgentSession { const next = scopedModels[nextIndex]; await this.setModel(next.model, "default", { cause: "user-selection" }); - - // Apply the scoped model's configured thinking level + // Apply the scoped model's configured thinking level before persisting + // the materialized selector. this.setThinkingLevel(next.thinkingLevel); + this.#clearActiveModelProfileForConcreteDefault("user-selection"); return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true }; } @@ -11290,6 +11592,9 @@ export class AgentSession { } await this.setModel(nextModel, "default", { cause: "user-selection" }); + // Cycling is a concrete default materialization with no TUI + // materialization step; clear the active-profile marker. + this.#clearActiveModelProfileForConcreteDefault("user-selection"); // Re-apply the current thinking level for the newly selected model this.setThinkingLevel(this.thinkingLevel); @@ -16919,6 +17224,10 @@ export class AgentSession { : configuredServiceTier === "none" ? undefined : configuredServiceTier; + // Switching to another session file must not carry the predecessor's + // profile marker or role overrides into the successor; the successor's + // own configured model is restored above. + if (switchingToDifferentSession) this.#resetSessionScopedModelProfileState(); // Establish the successor's durable session identity only after every // restored state facet is live. Identity-bound extension hooks run below. await this.sessionManager.ensureOnDisk(); diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index ee31754081..e80c2c2f47 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -1847,8 +1847,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise session.setThinkingLevelForControl(level, persist), setThinkingVisibilityForControl: (visibility, persist) => session.setThinkingVisibilityForControl(visibility, persist), - setModelTemporaryForControl: (model, expectedSessionId) => - session.setModelTemporaryForControl(model, expectedSessionId), + setModelTemporaryForControl: (model, expectedSessionId, thinkingLevel) => + session.setModelTemporaryForControl(model, expectedSessionId, thinkingLevel), fetchUsageReportsForControl: () => session.fetchUsageReportsForControl(), getThinkingScopeForControl: () => session.getThinkingScopeForControl(), getSessionName: () => session.sessionManager.getSessionName(), diff --git a/packages/coding-agent/test/agent-session-model-change-cause.test.ts b/packages/coding-agent/test/agent-session-model-change-cause.test.ts index dfe1765067..bff218b125 100644 --- a/packages/coding-agent/test/agent-session-model-change-cause.test.ts +++ b/packages/coding-agent/test/agent-session-model-change-cause.test.ts @@ -81,11 +81,16 @@ describe("AgentSession model-change causes", () => { test("runExtensionSetModel passes user-selection cause", async () => { const model = { provider: "p", id: "m" } as unknown as Model; const calls: Array<{ role?: string; cause?: string }> = []; + const materialized: Model[] = []; const session = { modelRegistry: { getApiKey: async () => "key" }, setModel: async (_model: Model, role?: string, options?: { cause?: string }) => { calls.push({ role, cause: options?.cause }); }, + materializeActiveDefaultModelProfileAssignment: (selected: Model) => { + materialized.push(selected); + return true; + }, }; const ok = await runExtensionSetModel(session, model); @@ -93,6 +98,7 @@ describe("AgentSession model-change causes", () => { expect(ok).toBe(true); expect(calls).toHaveLength(1); expect(calls[0]).toEqual({ role: "default", cause: "user-selection" }); + expect(materialized).toEqual([model]); }); test("runExtensionSetModel returns false without an API key and does not set the model", async () => { diff --git a/packages/coding-agent/test/gc-session-scope.test.ts b/packages/coding-agent/test/gc-session-scope.test.ts new file mode 100644 index 0000000000..653c523643 --- /dev/null +++ b/packages/coding-agent/test/gc-session-scope.test.ts @@ -0,0 +1,103 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + collectSessionScopeUsage, + type GcSessionScopeUsage, + shouldReportSessionScope, +} from "../src/gjc-runtime/gc-session-scope"; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +function scopeWith(files: { name: string; bytes: number }[]): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "gjc-gc-scope-")); + temporaryDirectories.push(root); + for (const file of files) { + const full = path.join(root, file.name); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, Buffer.alloc(file.bytes, 0)); + } + return root; +} + +describe("gc session scope usage", () => { + it("reports a scope under the budget as ok and stays silent", async () => { + const root = scopeWith([{ name: "a.jsonl", bytes: 1024 }]); + const usage = await collectSessionScopeUsage(root, 1024 * 1024); + + expect(usage.status).toBe("ok"); + expect(usage.total_bytes).toBe(1024); + expect(usage.entries).toBe(1); + expect(usage.truncated).toBe(false); + // An ok scope must not be surfaced — gc output stays unchanged for + // everyone who is nowhere near the budget. + expect(shouldReportSessionScope(usage)).toBe(false); + }); + + it("flags a scope past the notice ratio before it fails", async () => { + // 80% of the budget: still launchable, but the operator should hear it. + const root = scopeWith([{ name: "big.jsonl", bytes: 800 }]); + const usage = await collectSessionScopeUsage(root, 1000); + + expect(usage.status).toBe("approaching_limit"); + expect(shouldReportSessionScope(usage)).toBe(true); + }); + + it("flags a scope over the budget", async () => { + const root = scopeWith([ + { name: "one.jsonl", bytes: 600 }, + { name: "nested/two.jsonl", bytes: 600 }, + ]); + const usage = await collectSessionScopeUsage(root, 1000); + + expect(usage.status).toBe("over_limit"); + expect(usage.total_bytes).toBe(1200); + // The nested directory is counted as an entry alongside its file. + expect(usage.entries).toBe(3); + expect(shouldReportSessionScope(usage)).toBe(true); + }); + + it("reports a missing scope as unavailable instead of throwing", async () => { + const usage = await collectSessionScopeUsage(path.join(os.tmpdir(), "gjc-gc-scope-does-not-exist"), 1000); + + expect(usage.status).toBe("unavailable"); + expect(usage.reason).toBe("scope_not_found"); + expect(shouldReportSessionScope(usage)).toBe(false); + }); + + it("reports a non-directory scope path as unavailable", async () => { + const root = scopeWith([{ name: "file", bytes: 1 }]); + const usage = await collectSessionScopeUsage(path.join(root, "file"), 1000); + + expect(usage.status).toBe("unavailable"); + expect(usage.reason).toBe("not_a_directory"); + }); + + it("keeps counting past an unreadable subtree rather than failing the probe", async () => { + const root = scopeWith([ + { name: "readable.jsonl", bytes: 500 }, + { name: "locked/inner.jsonl", bytes: 500 }, + ]); + const locked = path.join(root, "locked"); + fs.chmodSync(locked, 0o000); + + let usage: GcSessionScopeUsage; + try { + usage = await collectSessionScopeUsage(root, 1000); + } finally { + fs.chmodSync(locked, 0o700); + } + + // The unreadable subtree is skipped, but the readable side still counts, + // so the answer to "am I near the budget?" survives a partial walk. + expect(usage.status).not.toBe("unavailable"); + expect(usage.total_bytes).toBeGreaterThanOrEqual(500); + }); +}); diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index 277c813c8c..6f07b0c198 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -1857,6 +1857,16 @@ describe("ModelRegistry", () => { expect(model?.baseUrl).toBe("https://my-proxy.example.com/v1"); }); + test("bundled jetbrains-junie gpt-5.4 keeps its probed 922K window", () => { + const registry = new ModelRegistry(authStorage, modelsJsonPath); + // The generic gpt-5.4 policy raises the window to 1M for everyone except + // gateway-fronted providers. JetBrains AI enforces 922K, so the measured + // bundled value must survive; raising it would delay compaction past the + // point the gateway accepts. + expect(registry.find("jetbrains-junie", "gpt-5.4")?.contextWindow).toBe(922_000); + expect(registry.find("openai-codex", "gpt-5.4")?.contextWindow).toBe(1_000_000); + }); + test("discoverable custom-only gpt-5.4 survives refresh", async () => { writeRawModelsJson({ "custom-local": { diff --git a/packages/coding-agent/test/provider-ranking.test.ts b/packages/coding-agent/test/provider-ranking.test.ts index 529d51ebe1..74ae2ca835 100644 --- a/packages/coding-agent/test/provider-ranking.test.ts +++ b/packages/coding-agent/test/provider-ranking.test.ts @@ -117,6 +117,7 @@ describe("famous provider list", () => { "bizrouter", "mara", "github-copilot", + "jetbrains-junie", "cursor", ]; expect([...FAMOUS_PROVIDER_ORDER]).toEqual(agreedOrder); diff --git a/packages/coding-agent/test/sdk-acp-prompt-terminal.test.ts b/packages/coding-agent/test/sdk-acp-prompt-terminal.test.ts index 448e6b5b16..0b097eda79 100644 --- a/packages/coding-agent/test/sdk-acp-prompt-terminal.test.ts +++ b/packages/coding-agent/test/sdk-acp-prompt-terminal.test.ts @@ -46,6 +46,7 @@ async function createFixture( terminalBeforeAcknowledgement?: boolean; preAcknowledgementTerminal?: Record; promptAcknowledgement?: Record; + cancelSettlementGraceMs?: number; } = {}, ): Promise { const tempDir = TempDir.createSync("@sdk-acp-prompt-terminal-"); @@ -174,7 +175,9 @@ async function createFixture( result: frame.operation === "turn.prompt" ? (options.promptAcknowledgement ?? { commandId, turnId, accepted: true }) - : {}, + : frame.operation === "turn.abort" + ? { aborted: true } + : {}, }), ); }, @@ -201,7 +204,12 @@ async function createFixture( signal: abort.signal, closed: Promise.withResolvers().promise, } as unknown as AgentSideConnection, - { agentDir }, + { + agentDir, + ...(options.cancelSettlementGraceMs === undefined + ? {} + : { cancelSettlementGraceMs: options.cancelSettlementGraceMs }), + }, ); const created = await bounded(agent.newSession({ cwd, mcpServers: [] }), "new session"); await waitFor( @@ -411,17 +419,19 @@ for (const terminalType of ["agent_end", "agent_failed"] as const) { code: "connection_closed", }); await Bun.sleep(30); - expect(fixture.updates).toHaveLength(updatesBefore); + // The turn ended, so the client's running phase is released — but an invalid + // terminal carries no trustworthy usage or title, so nothing is queried for it. expect(fixture.queryCalls).toHaveLength(queriesBefore); expect( fixture.updates .slice(updatesBefore) - .some( + .filter( update => update.update.sessionUpdate === "session_info_update" && (update.update as { _meta?: { gjcPhase?: string } })._meta?.gjcPhase === "idle", ), - ).toBe(false); + ).toHaveLength(1); + expect(fixture.updates).toHaveLength(updatesBefore + 1); } finally { fixture.dispose(); } @@ -453,6 +463,77 @@ test("ACP preserves the fixed settlement-grace invalid-terminal rejection", asyn } }); +// Observed against a Paseo review session: the SDK refused to publish a terminal because +// agent-owned async work outlived the turn, and the ACP session was left running forever. +test("ACP releases the running phase and accepts a new prompt after a settlement-grace rejection", async () => { + const fixture = await createFixture(); + try { + const pending = prompt(fixture, "unsettled prompt resources"); + await bounded(fixture.promptDelivered, "prompt delivery"); + fixture.sendTerminal({ + type: "agent_failed", + sessionId: "prompt-terminal-session", + commandId: "prompt-terminal-command", + turnId: "prompt-terminal-turn", + error: { + code: "terminal_uncertain", + message: "Prompt resources did not settle before the terminalization grace expired.", + }, + }); + await expect(bounded(pending, "unsettled prompt rejection")).rejects.toMatchObject({ + code: "connection_closed", + }); + const lastUpdate = fixture.updates.at(-1); + expect(lastUpdate?.update.sessionUpdate).toBe("session_info_update"); + expect((lastUpdate?.update as { _meta?: { gjcRunning?: boolean } })._meta?.gjcRunning).toBe(false); + // The wedged session refused every later turn with `conflict`, which surfaced in + // the client as a permanent "a foreground turn is already active". + const next = prompt(fixture, "prompt after rejection"); + fixture.sendStopped("end_turn"); + expect(await bounded(next, "prompt after rejection")).toEqual({ stopReason: "end_turn" }); + } finally { + fixture.dispose(); + } +}); + +test("ACP settles a cancelled prompt when the aborted turn never publishes a terminal", async () => { + const fixture = await createFixture({ cancelSettlementGraceMs: 25 }); + try { + const pending = prompt(fixture, "cancel without terminal"); + await bounded(fixture.promptDelivered, "prompt delivery"); + const updatesBefore = fixture.updates.length; + await bounded(fixture.agent.cancel({ sessionId: fixture.sessionId }), "cancel acknowledgement"); + expect(await bounded(pending, "cancelled settlement")).toEqual({ stopReason: "cancelled" }); + expect( + fixture.updates + .slice(updatesBefore) + .filter( + update => + update.update.sessionUpdate === "session_info_update" && + (update.update as { _meta?: { gjcPhase?: string } })._meta?.gjcPhase === "idle", + ), + ).toHaveLength(1); + const next = prompt(fixture, "prompt after cancel"); + fixture.sendStopped("end_turn"); + expect(await bounded(next, "prompt after cancel")).toEqual({ stopReason: "end_turn" }); + } finally { + fixture.dispose(); + } +}); + +test("ACP keeps the authoritative terminal when it arrives inside the cancel grace", async () => { + const fixture = await createFixture({ cancelSettlementGraceMs: 1_000 }); + try { + const pending = prompt(fixture, "cancel with terminal"); + await bounded(fixture.promptDelivered, "prompt delivery"); + await bounded(fixture.agent.cancel({ sessionId: fixture.sessionId }), "cancel acknowledgement"); + fixture.sendStopped("refusal"); + expect(await bounded(pending, "terminal settlement")).toEqual({ stopReason: "refusal" }); + } finally { + fixture.dispose(); + } +}); + test("ACP suppresses partial and duplicate terminals after settlement", async () => { const fixture = await createFixture(); try { diff --git a/packages/coding-agent/test/sdk-control-dispatch.test.ts b/packages/coding-agent/test/sdk-control-dispatch.test.ts index 77a8888562..4016c54603 100644 --- a/packages/coding-agent/test/sdk-control-dispatch.test.ts +++ b/packages/coding-agent/test/sdk-control-dispatch.test.ts @@ -465,3 +465,41 @@ test("replays matching idempotency requests, rejects conflicts, and evicts LRU e }); expect(calls).toBe(258); }); +test("forwards synthetic gajae-code selections with an off thinking level and pins typed result values", async () => { + const model = OPERATIONS.find(row => row.sdkId === "model.set")!; + const calls: unknown[][] = []; + const surface = { + setModel: (...args: unknown[]) => { + calls.push(args); + return { provider: "gajae-code", modelId: "codex-eco", thinkingLevel: "off" }; + }, + } as unknown as ControlSurface; + + const response = await dispatchControl(surface, model, { + ...request(model), + input: { id: "gajae-code/codex-eco", thinkingLevel: "off" }, + }); + + expect(calls).toEqual([["gajae-code/codex-eco", "off"]]); + expect(response.ok).toBe(true); + expect(response.result).toEqual({ provider: "gajae-code", modelId: "codex-eco", thinkingLevel: "off" }); +}); + +test("forwards a synthetic selection without a thinking level as undefined", async () => { + const model = OPERATIONS.find(row => row.sdkId === "model.set")!; + const calls: unknown[][] = []; + const surface = { + setModel: (...args: unknown[]) => { + calls.push(args); + return { changed: true }; + }, + } as unknown as ControlSurface; + + const response = await dispatchControl(surface, model, { + ...request(model), + input: { id: "gajae-code/codex-eco" }, + }); + + expect(calls).toEqual([["gajae-code/codex-eco", undefined]]); + expect(response.result).toEqual({ changed: true }); +}); diff --git a/packages/coding-agent/test/sdk-default-model-selection-e2e.test.ts b/packages/coding-agent/test/sdk-default-model-selection-e2e.test.ts index d37fb22a6a..c370268705 100644 --- a/packages/coding-agent/test/sdk-default-model-selection-e2e.test.ts +++ b/packages/coding-agent/test/sdk-default-model-selection-e2e.test.ts @@ -3,6 +3,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { closeModelCache, Effort } from "@gajae-code/ai"; +import { YAML } from "bun"; import { ModelRegistry } from "../src/config/model-registry"; import { resetSettingsForTest, Settings } from "../src/config/settings"; import { initializeExtensions } from "../src/modes/runtime-init"; @@ -253,3 +254,608 @@ test("model.set executes every Q10-advertised selection and persists the public expect(freshSession.model?.id).toBe(persistedSelection.modelId); expect(freshSession.thinkingLevel).toBe(persistedSelection.thinkingLevel); }, 30000); +test("session-only profile restores the starting model without a durable default", async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-sdk-session-profile-successor-")); + const agentDir = path.join(tempDir, "agent"); + const fixtureEnv = createFixtureBrokerEnvironment(tempDir, agentDir); + const started = await withFixtureBrokerEnvironment(() => + startFixtureBrokerWithLeaseForTest({ agentDir, env: fixtureEnv }), + ); + fixtureCleanup = createFixtureRootCleanup(tempDir, agentDir, started.lease); + authStorage = await AuthStorage.create(path.join(agentDir, "auth.db")); + if (!fixtureCleanup) throw new Error("Expected fixture broker cleanup."); + registerFixtureRuntime(fixtureCleanup, { + key: "auth-storage", + requiredOwner: "runtime", + dispose: async () => authStorage?.close(), + }); + registerFixtureRuntime(fixtureCleanup, { + key: "model-cache", + requiredOwner: "runtime", + dispose: async () => void closeModelCache(path.join(agentDir, "models.db")), + }); + + await fs.mkdir(agentDir, { recursive: true }); + await fs.writeFile( + path.join(agentDir, "models.yml"), + YAML.stringify({ + profiles: { + "custom-eco": { + display_name: "Custom Eco", + required_providers: ["runtime-provider"], + model_mapping: { default: "runtime-provider/profile-model" }, + }, + }, + }), + ); + + const modelRegistry = new ModelRegistry(authStorage, path.join(agentDir, "models.yml")); + modelRegistry.registerProvider("runtime-provider", { + baseUrl: "http://127.0.0.1:9/v1", + apiKey: "RUNTIME_KEY", + api: "openai-completions", + models: [ + { + id: "initial-model", + name: "Initial Model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 8_192, + }, + { + id: "profile-model", + name: "Profile Model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 8_192, + }, + ], + }); + const settings = await Settings.init({ cwd: tempDir, agentDir }); + const initialModel = modelRegistry.find("runtime-provider", "initial-model"); + const profileModel = modelRegistry.find("runtime-provider", "profile-model"); + if (!initialModel || !profileModel) throw new Error("Expected profile model fixtures"); + vi.spyOn(modelRegistry, "getAll").mockReturnValue([initialModel, profileModel]); + + const { session } = await createAgentSession({ + cwd: tempDir, + agentDir, + authStorage, + modelRegistry, + settings, + model: initialModel, + sessionManager: SessionManager.inMemory(tempDir), + disableExtensionDiscovery: true, + extensions: [], + skills: [], + contextFiles: [], + promptTemplates: [], + slashCommands: [], + enableMCP: false, + enableLsp: false, + }); + if (!fixtureCleanup) throw new Error("Expected fixture broker cleanup."); + registerFixtureRuntime(fixtureCleanup, { + key: `session:${session.sessionId}`, + requiredOwner: "runtime-and-broker", + dispose: () => session.dispose(), + }); + + expect(settings.getGlobal("modelRoles")).toBeUndefined(); + expect(session.model?.id).toBe("initial-model"); + await session.setDefaultModelProfileForControl("custom-eco", { persistDefault: false }); + expect(session.getActiveModelProfile()).toBe("custom-eco"); + expect(session.model?.id).toBe("profile-model"); + expect(await session.newSession()).toBe(true); + expect(session.getActiveModelProfile()).toBeUndefined(); + // A failure leaks the session-only profile model and lets /new invent a durable default. + expect(session.model?.id).toBe("initial-model"); + expect(settings.getGlobal("modelRoles")).toBeUndefined(); +}, 30000); +test("selecting a synthetic gajae-code profile remains session-scoped across concrete selection and restart", async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-sdk-synthetic-profile-")); + const agentDir = path.join(tempDir, "agent"); + const fixtureEnv = createFixtureBrokerEnvironment(tempDir, agentDir); + const started = await withFixtureBrokerEnvironment(() => + startFixtureBrokerWithLeaseForTest({ agentDir, env: fixtureEnv }), + ); + fixtureCleanup = createFixtureRootCleanup(tempDir, agentDir, started.lease); + authStorage = await AuthStorage.create(path.join(agentDir, "auth.db")); + if (!fixtureCleanup) throw new Error("Expected fixture broker cleanup."); + registerFixtureRuntime(fixtureCleanup, { + key: "auth-storage", + requiredOwner: "runtime", + dispose: async () => authStorage?.close(), + }); + registerFixtureRuntime(fixtureCleanup, { + key: "model-cache", + requiredOwner: "runtime", + dispose: async () => void closeModelCache(path.join(agentDir, "models.db")), + }); + + // A custom profile requiring the authenticated fixture provider makes the + // synthetic facade selectable in the Q10 catalog. + await fs.mkdir(agentDir, { recursive: true }); + const modelsYml = YAML.stringify({ + profiles: { + "custom-eco": { + display_name: "Custom Eco", + required_providers: ["runtime-provider"], + model_mapping: { + default: "runtime-provider/initial-model", + executor: "runtime-provider/executor-model", + planner: "runtime-provider/planner-model", + critic: "runtime-provider/critic-model", + architect: "runtime-provider/architect-model", + }, + }, + // A default-only successor profile must drop the previous profile's + // role-agent mappings when activated after a full profile. + "default-only-eco": { + display_name: "Default Only Eco", + required_providers: ["runtime-provider"], + model_mapping: { + default: "runtime-provider/initial-model", + }, + }, + }, + // Configured modelBindings share the modelRoles/task.agentModelOverrides + // runtime override slots with profile activations and must survive + // session transitions and concrete picks. The critic binding lives in the + // same agentModelOverrides slot the profile's critic mapping uses. + modelBindings: { + agentModelOverrides: { + critic: "runtime-provider/reasoning-model", + }, + }, + }); + await fs.writeFile(path.join(agentDir, "models.yml"), modelsYml); + + const modelRegistry = new ModelRegistry(authStorage, path.join(agentDir, "models.yml")); + modelRegistry.registerProvider("runtime-provider", { + baseUrl: "http://127.0.0.1:9/v1", + apiKey: "RUNTIME_KEY", + api: "openai-completions", + models: [ + { + id: "initial-model", + name: "Initial Model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 8_192, + }, + { + id: "reasoning-model", + name: "Reasoning Model", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 8_192, + thinking: { + minLevel: Effort.Minimal, + maxLevel: Effort.High, + mode: "effort", + defaultLevel: Effort.Low, + }, + }, + { + id: "executor-model", + name: "Executor Model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 8_192, + }, + { + id: "planner-model", + name: "Planner Model", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 8_192, + }, + { + id: "critic-model", + name: "Critic Model", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 8_192, + }, + { + id: "architect-model", + name: "Architect Model", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 8_192, + }, + ], + }); + const settings = await Settings.init({ cwd: tempDir, agentDir }); + const initialModel = modelRegistry.find("runtime-provider", "initial-model"); + if (!initialModel) throw new Error("Expected initial model fixture"); + vi.spyOn(modelRegistry, "getAll").mockReturnValue([ + initialModel, + modelRegistry.find("runtime-provider", "reasoning-model")!, + modelRegistry.find("runtime-provider", "executor-model")!, + modelRegistry.find("runtime-provider", "planner-model")!, + modelRegistry.find("runtime-provider", "critic-model")!, + modelRegistry.find("runtime-provider", "architect-model")!, + ]); + + const { session } = await createAgentSession({ + cwd: tempDir, + agentDir, + authStorage, + modelRegistry, + settings, + model: initialModel, + sessionManager: SessionManager.inMemory(tempDir), + disableExtensionDiscovery: true, + extensions: [], + skills: [], + contextFiles: [], + promptTemplates: [], + slashCommands: [], + enableMCP: false, + enableLsp: false, + }); + if (!fixtureCleanup) throw new Error("Expected fixture broker cleanup."); + registerFixtureRuntime(fixtureCleanup, { + key: `session:${session.sessionId}`, + requiredOwner: "runtime-and-broker", + shutdown: async () => void (await session.extensionRunner?.emit({ type: "session_shutdown" })), + dispose: () => session.dispose(), + }); + await initializeExtensions(session, { reportSendError: () => {}, reportRuntimeError: () => {} }); + + const endpointFile = path.join(tempDir, ".gjc", "state", "sdk", `${session.sessionId}.json`); + const deadline = Date.now() + 4_000; + while (!(await Bun.file(endpointFile).exists())) { + if (Date.now() > deadline) throw new Error("Timed out starting SDK host"); + await Bun.sleep(10); + } + const endpoint = (await Bun.file(endpointFile).json()) as { url: string; token: string }; + const client = await SdkClient.connect(endpoint.url, endpoint.token, { + timeoutMs: SDK_REQUEST_TIMEOUT_MS, + reconnectAttempts: 0, + }); + + try { + const catalog = (await client.query("Q10")) as { page?: { items: Q10Model[] } }; + const syntheticRow = catalog.page?.items.find(row => row.provider === "gajae-code" && row.id === "custom-eco"); + expect(syntheticRow).toMatchObject({ + provider: "gajae-code", + id: "custom-eco", + name: "Custom Eco", + reasoning: false, + thinking: { validLevels: ["off"] }, + current: false, + }); + + const selection = await client.control("model.set", { id: "gajae-code/custom-eco" }); + // Q27 remains the full profile catalog and agrees with the Q10 + // availability facade on the shared authenticated-provider derivation. + const profiles = (await client.query("Q27")) as { + page?: { items: Array<{ id: string; available?: boolean }> }; + }; + const customEcoProfile = profiles.page?.items.find(item => item.id === "custom-eco"); + expect(customEcoProfile).toMatchObject({ id: "custom-eco", available: true }); + expect((selection as { ok?: boolean; result?: unknown }).ok).toBe(true); + expect((selection as { result?: unknown }).result).toEqual({ changed: true }); + + // Concurrent synthetic selections serialize through the session admission + // queue (FIFO); a racing config.patch remains durable because session-scoped + // activation never rewrites persisted profile-owned settings. + const concurrent = await Promise.all([ + client.control("model.set", { id: "gajae-code/custom-eco" }), + client.control("model.set", { id: "gajae-code/custom-eco" }), + ]); + expect(concurrent.every(result => (result as { ok?: boolean }).ok === true)).toBe(true); + const racyPatch = await Promise.all([ + client.control("config.patch", { + patch: { + modelRoles: { default: "runtime-provider/initial-model" }, + cycleOrder: ["default", "executor"], + }, + }), + client.control("model.set", { id: "gajae-code/custom-eco" }), + ]); + expect((racyPatch[0] as { ok?: boolean }).ok).toBe(true); + expect((racyPatch[1] as { ok?: boolean }).ok).toBe(true); + expect(settings.getGlobal("modelRoles")).toEqual({ default: "runtime-provider/initial-model" }); + expect(settings.getGlobal("modelProfile.default")).toBeUndefined(); + expect(session.getActiveModelProfile()).toBe("custom-eco"); + expect(session.model?.provider).toBe("runtime-provider"); + expect(session.model?.id).toBe("initial-model"); + expect(settings.getGlobal("modelProfile.default")).toBeUndefined(); + expect(settings.getGlobal("modelRoles")).toEqual({ default: "runtime-provider/initial-model" }); + // The config query shadow remains authoritative because session-scoped + // activation does not rewrite either patched setting. + const configAfter = (await client.query("Q13")) as { page?: { items: unknown[] } }; + const configFlat = (configAfter.page?.items ?? []).flatMap(item => { + const record = item as Record; + if (typeof record.id === "string") return [[record.id, record.value] as const]; + return Object.entries(record); + }); + expect(configFlat.some(([key]) => key === "modelRoles")).toBe(true); + expect(configFlat.some(([key]) => key === "cycleOrder")).toBe(true); + // The FULL preset is applied to the live session: every role mapping from + // the profile is active as a runtime override (executor/planner/critic/ + // architect -> task.agentModelOverrides) without changing durable roles. + expect(settings.get("task.agentModelOverrides")).toEqual({ + executor: "runtime-provider/executor-model", + planner: "runtime-provider/planner-model", + critic: "runtime-provider/critic-model", + architect: "runtime-provider/architect-model", + }); + const afterCatalog = (await client.query("Q10")) as { page?: { items: Q10Model[] } }; + const currentRows = afterCatalog.page?.items.filter(row => row.current); + expect(currentRows).toHaveLength(1); + expect(currentRows?.[0]).toMatchObject({ + provider: "gajae-code", + id: "custom-eco", + current: true, + currentThinkingLevel: "inherit", + }); + + // A concrete selection clears the session-only marker without materializing + // profile assignments or writing a global profile default. + await client.control("model.set", { id: "runtime-provider/initial-model" }); + expect(session.getActiveModelProfile()).toBeUndefined(); + expect(settings.getGlobal("modelProfile.default")).toBeUndefined(); + expect(settings.getGlobal("modelRoles")).toEqual({ default: "runtime-provider/initial-model" }); + + const concreteCatalog = (await client.query("Q10")) as { page?: { items: Q10Model[] } }; + const concreteCurrent = concreteCatalog.page?.items.filter(row => row.current); + expect(concreteCurrent).toHaveLength(1); + expect(concreteCurrent?.[0]).toMatchObject({ provider: "runtime-provider", id: "initial-model" }); + + // Registry-error fail-closed: an invalid models.yml must not advertise + // synthetic rows (selection would reject them via the same registry + // error) while the concrete catalog stays queryable. + await fs.writeFile(path.join(agentDir, "models.yml"), "profiles: [broken\n"); + await modelRegistry.refresh("offline"); + expect(modelRegistry.getError?.()).toBeDefined(); + const errorCatalog = (await client.query("Q10")) as { page?: { items: Q10Model[] } }; + expect(errorCatalog.page?.items.some(row => row.provider === "gajae-code")).toBe(false); + expect(errorCatalog.page?.items.some(row => row.provider === "runtime-provider")).toBe(true); + + // Restore a valid configuration so the fresh-launch reapply below works. + await fs.writeFile(path.join(agentDir, "models.yml"), modelsYml); + await modelRegistry.refresh("offline"); + expect(modelRegistry.getError?.()).toBeUndefined(); + await client.control("model.set", { id: "gajae-code/custom-eco" }); + expect(session.getActiveModelProfile()).toBe("custom-eco"); + + // Reserved-namespace collision: a real provider named `gajae-code` makes + // `gajae-code/*` ids ambiguous, so Q10 must not advertise ANY row from + // that namespace — neither the colliding provider's concrete models nor + // synthetic profiles — while other providers stay queryable. + await fs.writeFile( + path.join(agentDir, "models.yml"), + YAML.stringify({ + providers: { + "runtime-provider": { + baseUrl: "http://127.0.0.1:9/v1", + apiKey: "RUNTIME_KEY", + api: "openai-completions", + models: [ + { + id: "initial-model", + name: "Initial Model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 8_192, + }, + ], + }, + "gajae-code": { + baseUrl: "http://127.0.0.1:9/v1", + apiKey: "RUNTIME_KEY", + api: "openai-completions", + models: [ + { + id: "shadow-model", + name: "Shadow Model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 8_192, + }, + ], + }, + }, + profiles: { + "custom-eco": { + display_name: "Custom Eco", + required_providers: ["runtime-provider"], + model_mapping: { default: "runtime-provider/initial-model" }, + }, + // A profile whose default mapping cannot activate (its + // required provider is authenticated but the default maps to + // an unauthenticated provider) must not be advertised. + "broken-default": { + display_name: "Broken Default", + required_providers: ["runtime-provider"], + model_mapping: { default: "no-such-provider/ghost-model" }, + }, + }, + }), + ); + await modelRegistry.refresh("offline"); + vi.spyOn(modelRegistry, "getAll").mockReturnValue([ + initialModel, + modelRegistry.find("runtime-provider", "reasoning-model")!, + modelRegistry.find("gajae-code", "shadow-model")!, + ]); + const collisionCatalog = (await client.query("Q10")) as { page?: { items: Q10Model[] } }; + expect(collisionCatalog.page?.items.some(row => row.provider === "gajae-code")).toBe(false); + expect(collisionCatalog.page?.items.some(row => row.provider === "runtime-provider")).toBe(true); + expect( + collisionCatalog.page?.items.some(row => row.provider === "gajae-code" && row.id === "broken-default"), + ).toBe(false); + // An already-active synthetic profile must not reinstate a collided + // synthetic value through Q13/ACP current-value fallback. + const collisionConfig = (await client.query("Q13")) as { page?: { items: unknown[] } }; + const collisionModel = (collisionConfig.page?.items ?? []) + .flatMap(item => { + const record = item as Record; + return typeof record.id === "string" ? [[record.id, record.value] as const] : Object.entries(record); + }) + .find(([key]) => key === "model")?.[1]; + expect(collisionModel).toBe("runtime-provider/initial-model"); + + // A session transition must not carry the profile into the successor: + // session.new resets the marker and the runtime role overrides so the + // successor session reports its own concrete model as current. Verify + // directly on the session because the SDK endpoint closes on the + // identity rotation. Restore the non-collided registry first. + await fs.writeFile(path.join(agentDir, "models.yml"), modelsYml); + await modelRegistry.refresh("offline"); + vi.spyOn(modelRegistry, "getAll").mockReturnValue([ + initialModel, + modelRegistry.find("runtime-provider", "reasoning-model")!, + modelRegistry.find("runtime-provider", "executor-model")!, + modelRegistry.find("runtime-provider", "planner-model")!, + modelRegistry.find("runtime-provider", "critic-model")!, + modelRegistry.find("runtime-provider", "architect-model")!, + ]); + expect(modelRegistry.getError?.()).toBeUndefined(); + await client.control("model.set", { id: "gajae-code/custom-eco" }); + expect(session.getActiveModelProfile()).toBe("custom-eco"); + expect(settings.get("task.agentModelOverrides")).toEqual({ + executor: "runtime-provider/executor-model", + planner: "runtime-provider/planner-model", + critic: "runtime-provider/critic-model", + architect: "runtime-provider/architect-model", + }); + // While active, the profile overrides the configured critic binding. + expect(settings.get("task.agentModelOverrides")?.critic).toBe("runtime-provider/critic-model"); + + // A concrete selection after a session-only profile drops the marker AND + // the runtime role overrides the profile activation installed, while the + // configured modelBinding for critic survives the drop. + await client.control("model.set", { id: "runtime-provider/initial-model" }); + expect(session.getActiveModelProfile()).toBeUndefined(); + expect(settings.get("task.agentModelOverrides")).toEqual({ critic: "runtime-provider/reasoning-model" }); + expect(settings.getGlobal("modelRoles")).toEqual({ default: "runtime-provider/initial-model" }); + + // A session transition must not carry the profile into the successor: + // session.new resets the marker and the runtime role overrides so the + // successor session reports its own concrete model as current. Verify + // directly on the session because the SDK endpoint closes on the + // identity rotation. + await client.control("model.set", { id: "gajae-code/custom-eco" }); + expect(session.getActiveModelProfile()).toBe("custom-eco"); + expect(settings.get("task.agentModelOverrides")).toEqual({ + executor: "runtime-provider/executor-model", + planner: "runtime-provider/planner-model", + critic: "runtime-provider/critic-model", + architect: "runtime-provider/architect-model", + }); + expect(await session.newSession()).toBe(true); + expect(session.getActiveModelProfile()).toBeUndefined(); + // The profile's role overrides are gone; the durable schema default is + // an empty record rather than the profile's four-role mapping, while the + // configured critic binding survives the transition. + expect(settings.get("task.agentModelOverrides")).toEqual({ critic: "runtime-provider/reasoning-model" }); + expect(session.model?.provider).toBe("runtime-provider"); + expect(session.model?.id).toBe("initial-model"); + + // A DURABLE profile survives the transition: /new keeps its marker and + // runtime role overrides because the startup policy reapplies it on a + // fresh launch. + await session.setDefaultModelProfileForControl("custom-eco", { persistDefault: true }); + expect(session.getActiveModelProfile()).toBe("custom-eco"); + expect(settings.getGlobal("modelProfile.default")).toBe("custom-eco"); + expect(settings.get("task.agentModelOverrides")).toEqual({ + executor: "runtime-provider/executor-model", + planner: "runtime-provider/planner-model", + critic: "runtime-provider/critic-model", + architect: "runtime-provider/architect-model", + }); + expect(await session.newSession()).toBe(true); + expect(session.getActiveModelProfile()).toBe("custom-eco"); + expect(settings.getGlobal("modelProfile.default")).toBe("custom-eco"); + expect(settings.get("task.agentModelOverrides")).toEqual({ + executor: "runtime-provider/executor-model", + planner: "runtime-provider/planner-model", + critic: "runtime-provider/critic-model", + architect: "runtime-provider/architect-model", + }); + + // A default-only successor profile drops the previous profile's role-agent + // mappings even though it contributes none of its own. + await session.setDefaultModelProfileForControl("default-only-eco", { persistDefault: false }); + expect(session.getActiveModelProfile()).toBe("default-only-eco"); + expect(settings.get("task.agentModelOverrides")).toEqual({}); + // The durable custom-eco default is still configured but no longer + // matches the session-only marker; a concrete pick supersedes it. + expect(settings.getGlobal("modelProfile.default")).toBe("custom-eco"); + await session.setDefaultModelSelection(initialModel, undefined); + expect(session.getActiveModelProfile()).toBeUndefined(); + expect(settings.getGlobal("modelProfile.default")).toBeUndefined(); + expect(settings.getGlobal("modelRoles")).toEqual({ default: "runtime-provider/initial-model:off" }); + expect(settings.get("task.agentModelOverrides")).toEqual({ critic: "runtime-provider/reasoning-model" }); + + // Restore the session-only baseline so the fresh-launch assertions below + // expect no persisted profile. + await session.setDefaultModelProfileForControl("custom-eco", { persistDefault: false }); + expect(await session.newSession()).toBe(true); + expect(session.getActiveModelProfile()).toBeUndefined(); + expect(settings.getGlobal("modelProfile.default")).toBeUndefined(); + expect(settings.get("task.agentModelOverrides")).toEqual({ critic: "runtime-provider/reasoning-model" }); + } finally { + await client.close(); + } + + // A fresh launch must not inherit a profile selected through ACP. The fresh + // session does not need an SDK endpoint, so disable hosting to avoid an + // async discovery-file write racing fixture-root removal during cleanup. + process.env.GJC_SDK_DISABLE = "1"; + const { session: freshSession } = await createAgentSession({ + cwd: tempDir, + agentDir, + authStorage, + modelRegistry, + settings, + sessionManager: SessionManager.inMemory(tempDir), + disableExtensionDiscovery: true, + extensions: [], + skills: [], + contextFiles: [], + promptTemplates: [], + slashCommands: [], + enableMCP: false, + enableLsp: false, + }); + if (!fixtureCleanup) throw new Error("Expected fixture broker cleanup."); + registerFixtureRuntime(fixtureCleanup, { + key: `session:${freshSession.sessionId}`, + requiredOwner: "runtime-and-broker", + dispose: () => freshSession.dispose(), + }); + expect(freshSession.getActiveModelProfile()).toBeUndefined(); + expect(settings.getGlobal("modelProfile.default")).toBeUndefined(); + + // Let any in-flight SDK-host/broker discovery writes settle before the + // fixture cleanup removes the root, so the absence observation never sees a + // stale async write recreate the fixture root. + await Bun.sleep(200); +}, 30_000); diff --git a/packages/coding-agent/test/sdk-host-wiring.test.ts b/packages/coding-agent/test/sdk-host-wiring.test.ts index 6388cca7b2..d308d43384 100644 --- a/packages/coding-agent/test/sdk-host-wiring.test.ts +++ b/packages/coding-agent/test/sdk-host-wiring.test.ts @@ -1045,6 +1045,57 @@ test("interactive extension context advertises typed SDK controls and forwards p ).rejects.toMatchObject({ code: "invalid_input" }); }); +test("interactive SDK control routes synthetic gajae-code selections to session-scoped activation", async () => { + let contextActions: ExtensionContextActions | undefined; + let activated: { name: string; options: unknown } | undefined; + let thinkingLevel: string | undefined; + const runner = { + initialize( + _actions: ExtensionActions, + actions: ExtensionContextActions, + _commands: unknown, + _ui: ExtensionUIContext, + ): void { + contextActions = actions; + }, + }; + const controller = new ExtensionUiController({ + session: { + extensionRunner: runner, + modelRegistry: { + find: () => undefined, + getModelProfiles: () => + new Map([["codex-eco", { name: "codex-eco", requiredProviders: [], modelMapping: {} }]]), + getError: () => undefined, + }, + setDefaultModelProfileForControl: async (name: string, options?: unknown) => { + activated = { name, options }; + thinkingLevel = "off"; + return { changed: true, id: name }; + }, + getActiveModelProfile: () => undefined, + get thinkingLevel() { + return thinkingLevel; + }, + }, + } as unknown as InteractiveModeContext); + controller.initializeHookRunner({} as ExtensionUIContext, false); + + expect( + await contextActions?.sdkControl?.("model.set", { + id: "gajae-code/codex-eco", + thinkingLevel: "off", + }), + ).toEqual({ provider: "gajae-code", modelId: "codex-eco", thinkingLevel: "off" }); + expect(activated).toEqual({ + name: "codex-eco", + options: { persistDefault: false, thinkingLevelOverride: "off" }, + }); + + await expect( + contextActions?.sdkControl?.("model.set", { id: "gajae-code/codex-eco", thinkingLevel: "high" }), + ).rejects.toMatchObject({ code: "invalid_input" }); +}); test("interactive session.handoff SDK control threads focus instructions to session.handoff", async () => { let contextActions: ExtensionContextActions | undefined; const handoffCalls: (string | undefined)[] = []; @@ -3772,6 +3823,41 @@ test("SDK endpoint applies typed skill, plan, goal, and config controls with obs ok: false, error: { code: "invalid_input", message: "config.patch rejects secret fields at the SDK host." }, }); + // Invalid values must be rejected before any durable write: a numeric + // cycleOrder would later break getRoleModelCycleCandidateCount()'s for...of. + expect( + await request("invalid-type-error", { + type: "control_request", + id: "invalid-type-error", + operation: "config.patch", + input: { patch: { cycleOrder: 1 } }, + }), + ).toEqual({ + type: "control_response", + id: "invalid-type-error", + ok: false, + error: { + code: "invalid_input", + message: "config.patch rejects invalid settings: cycleOrder (Expected array.)", + }, + }); + // Unknown paths are rejected the same way, with no durable side effects. + expect( + await request("unknown-path-error", { + type: "control_request", + id: "unknown-path-error", + operation: "config.patch", + input: { patch: { noSuchSetting: true } }, + }), + ).toEqual({ + type: "control_response", + id: "unknown-path-error", + ok: false, + error: { + code: "invalid_input", + message: "config.patch rejects invalid settings: noSuchSetting (Setting is not recognized by this version.)", + }, + }); expect(configWrites).toEqual([]); }); @@ -5384,7 +5470,7 @@ test("AC2/AC8: SDK host completes successful session mutations over its live Web compact: async () => { compactions++; }, - getConfigItems: () => ({ "ui.theme": "light" }), + getConfigItems: () => ({ "theme.dark": "light" }), }; process.env.GJC_NOTIFICATIONS = "1"; start(ctx, settings); @@ -5492,7 +5578,7 @@ test("AC2/AC8: SDK host completes successful session mutations over its live Web type: "control_request", id: "config-patch", operation: "config.patch", - input: { patch: { "ui.theme": "dark" } }, + input: { patch: { "theme.dark": "dark" } }, expectedRevision: "0", idempotencyKey: "successful-verbs-config-patch", }), @@ -5500,9 +5586,27 @@ test("AC2/AC8: SDK host completes successful session mutations over its live Web type: "control_response", id: "config-patch", ok: true, - result: { patched: ["ui.theme"], revision: "1" }, + result: { patched: ["theme.dark"], revision: "1" }, + }); + expect( + await request("config-patch-repeat", { + type: "control_request", + id: "config-patch-repeat", + operation: "config.patch", + input: { patch: { "theme.dark": "light" } }, + expectedRevision: "1", + idempotencyKey: "successful-verbs-config-patch-repeat", + }), + ).toEqual({ + type: "control_response", + id: "config-patch-repeat", + ok: true, + result: { patched: ["theme.dark"], revision: "2" }, }); - expect(configWrites).toEqual([["ui.theme", "dark"]]); + expect(configWrites).toEqual([ + ["theme.dark", "dark"], + ["theme.dark", "light"], + ]); expect( await request("config-readback", { type: "query_request", @@ -5513,7 +5617,7 @@ test("AC2/AC8: SDK host completes successful session mutations over its live Web type: "query_response", id: "config-readback", ok: true, - page: { items: [{ "ui.theme": "dark" }] }, + page: { items: [{ "theme.dark": "light" }] }, }); }); diff --git a/packages/coding-agent/test/sdk-model-profile-model.test.ts b/packages/coding-agent/test/sdk-model-profile-model.test.ts new file mode 100644 index 0000000000..bf3e394636 --- /dev/null +++ b/packages/coding-agent/test/sdk-model-profile-model.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "bun:test"; +import { kNoAuth } from "../src/config/model-auth"; +import type { ModelProfileDefinition } from "../src/config/model-profiles"; +import { + buildSyntheticModelId, + collectAuthenticatedProfileProviders, + isSyntheticModelId, + parseSyntheticModelId, + resolveSyntheticModelSelection, + SYNTHETIC_PROVIDER_ID, + syntheticNamespaceCollision, +} from "../src/sdk/model-profile-model"; + +const profile = (overrides: Partial = {}): ModelProfileDefinition => ({ + name: "codex-eco", + requiredProviders: ["openai-codex"], + modelMapping: { default: "openai-codex/gpt-5.6-terra:low" }, + source: "builtin", + ...overrides, +}); + +describe("synthetic model id grammar", () => { + it("builds and recognizes ids in the reserved namespace", () => { + expect(buildSyntheticModelId("codex-eco")).toBe(`${SYNTHETIC_PROVIDER_ID}/codex-eco`); + expect(isSyntheticModelId(`${SYNTHETIC_PROVIDER_ID}/codex-eco`)).toBe(true); + expect(isSyntheticModelId("openai-codex/gpt-5.6-terra")).toBe(false); + expect(isSyntheticModelId(SYNTHETIC_PROVIDER_ID)).toBe(false); + expect(isSyntheticModelId(`${SYNTHETIC_PROVIDER_ID}/`)).toBe(true); // empty suffix handled by parser + }); + + it("parses the profile suffix losslessly after the first namespace slash", () => { + expect(parseSyntheticModelId(`${SYNTHETIC_PROVIDER_ID}/codex-eco`)).toEqual({ profileName: "codex-eco" }); + // Profile ids are not constrained to the TUI wizard's lowercase pattern: + // additional slashes and punctuation are part of the suffix, never escapes. + expect(parseSyntheticModelId(`${SYNTHETIC_PROVIDER_ID}/custom/profile !`)).toEqual({ + profileName: "custom/profile !", + }); + expect(parseSyntheticModelId(`${SYNTHETIC_PROVIDER_ID}/`)).toBeUndefined(); + expect(parseSyntheticModelId("openai-codex/gpt-5.6-terra")).toBeUndefined(); + expect(parseSyntheticModelId(SYNTHETIC_PROVIDER_ID)).toBeUndefined(); + }); + + it("detects a real provider shadowing the reserved namespace", () => { + expect(syntheticNamespaceCollision([{ provider: "openai-codex" }])).toBe(false); + expect(syntheticNamespaceCollision([{ provider: SYNTHETIC_PROVIDER_ID }])).toBe(true); + expect(syntheticNamespaceCollision([{ provider: "a" }, { provider: SYNTHETIC_PROVIDER_ID }])).toBe(true); + expect(syntheticNamespaceCollision([{ provider: "openai-codex" }], [SYNTHETIC_PROVIDER_ID])).toBe(true); + }); +}); + +describe("synthetic model selection resolution", () => { + const profiles = new Map([ + ["codex-eco", profile()], + ["codex-medium", profile({ name: "codex-medium" })], + ]); + + it("canonicalizes legacy aliases exactly like the CLI", () => { + profiles.set("codex-medium", profile({ name: "codex-medium" })); + const resolved = resolveSyntheticModelSelection(`${SYNTHETIC_PROVIDER_ID}/codex-standard`, profiles); + expect(resolved.canonicalName).toBe("codex-medium"); + }); + + it("resolves known profiles and preserves the raw suffix", () => { + const resolved = resolveSyntheticModelSelection(`${SYNTHETIC_PROVIDER_ID}/codex-eco`, profiles); + expect(resolved).toEqual({ profileName: "codex-eco", canonicalName: "codex-eco" }); + }); + + it("converts unknown profiles to the SDK invalid_input code", () => { + const error = (() => { + try { + resolveSyntheticModelSelection(`${SYNTHETIC_PROVIDER_ID}/no-such-profile`, profiles); + } catch (caught) { + return caught as { code?: string }; + } + })(); + expect(error).toBeDefined(); + expect(error?.code).toBe("invalid_input"); + }); + + it("converts an empty suffix and a registry error to invalid_input", () => { + const empty = (() => { + try { + resolveSyntheticModelSelection(`${SYNTHETIC_PROVIDER_ID}/`, profiles); + } catch (caught) { + return caught as { code?: string }; + } + })(); + expect(empty?.code).toBe("invalid_input"); + + const broken = (() => { + try { + resolveSyntheticModelSelection(`${SYNTHETIC_PROVIDER_ID}/codex-eco`, profiles, new Error("broken")); + } catch (caught) { + return caught as { code?: string }; + } + })(); + expect(broken?.code).toBe("invalid_input"); + }); + + it("never selects a real provider model under the reserved namespace", () => { + const error = (() => { + try { + resolveSyntheticModelSelection("openai-codex/gpt-5.6-terra", profiles); + } catch (caught) { + return caught as { code?: string }; + } + })(); + expect(error?.code).toBe("invalid_input"); + }); +}); + +describe("authenticated profile provider collection", () => { + const alternativeProfiles = new Map([ + [ + "mimo", + profile({ + name: "mimo", + requiredProviders: ["xiaomi"], + alternativeProviderGroups: [["xiaomi", "xiaomi-token-plan-sgp"]], + }), + ], + ]); + + it("collects credentials across strict requirements and alternative groups", async () => { + const authenticated = await collectAuthenticatedProfileProviders(alternativeProfiles, async provider => { + if (provider === "xiaomi-token-plan-sgp") return "sk-test"; + return undefined; + }); + expect(authenticated.has("xiaomi")).toBe(false); + expect(authenticated.has("xiaomi-token-plan-sgp")).toBe(true); + }); + + it("counts kNoAuth as available and swallows per-provider lookup failures", async () => { + const authenticated = await collectAuthenticatedProfileProviders(alternativeProfiles, async provider => { + if (provider === "xiaomi-token-plan-sgp") return kNoAuth; + throw new Error("credential read failed"); + }); + // kNoAuth on the alternative group member; the strict provider failed. + expect(authenticated.has("xiaomi-token-plan-sgp")).toBe(true); + expect(authenticated.has("xiaomi")).toBe(false); + }); +}); diff --git a/packages/coding-agent/test/sdk-q10-models.test.ts b/packages/coding-agent/test/sdk-q10-models.test.ts index c8eba66a83..9e1246e504 100644 --- a/packages/coding-agent/test/sdk-q10-models.test.ts +++ b/packages/coding-agent/test/sdk-q10-models.test.ts @@ -147,3 +147,150 @@ describe("Q10 model projection", () => { ).toThrow(`Invalid thinking metadata for test/reasoning: ${reason}`); }); }); +describe("Q10 synthetic profile facade", () => { + const profile = ( + overrides: Record = {}, + ): { + name: string; + requiredProviders: string[]; + modelMapping: Record; + source: "builtin"; + displayName?: string; + } => ({ + name: "codex-eco", + requiredProviders: ["openai-codex"], + modelMapping: { default: "openai-codex/gpt-5.6-terra:low" }, + source: "builtin", + ...overrides, + }); + + const profiles = new Map< + string, + { + name: string; + requiredProviders: string[]; + modelMapping: Record; + source: "builtin"; + displayName?: string; + } + >([ + ["codex-eco", profile({ displayName: "Codex Eco" })], + ["codex-medium", profile({ name: "codex-medium" })], + ["opencodego", profile({ name: "opencodego", requiredProviders: ["opencode-go"] })], + ]); + + const plain = model({ + id: "plain", + name: "Plain model", + reasoning: false, + thinking: { mode: "effort", minLevel: Effort.Low, maxLevel: Effort.High }, + }); + + it("appends deterministic, availability-filtered synthetic rows after real rows", () => { + const rows = projectQ10Models({ + models: [plain], + profiles: profiles as unknown as Map, + availableProfileIds: new Set(["codex-eco", "codex-medium"]), + }); + expect(rows.map(row => `${row.provider}/${row.id}`)).toEqual([ + "test/plain", + "gajae-code/codex-eco", + "gajae-code/codex-medium", + ]); + expect(rows[1]).toMatchObject({ + provider: "gajae-code", + id: "codex-eco", + name: "Codex Eco", + reasoning: false, + thinking: { validLevels: [ThinkingLevel.Off] }, + current: false, + }); + }); + + it("uses the profile default model metadata with unknown constants as fallback", () => { + const rows = projectQ10Models({ + models: [plain], + profiles: profiles as unknown as Map, + availableProfileIds: new Set(["codex-eco", "opencodego"]), + resolveProfileDefaultModel: () => + model({ provider: "openai-codex", id: "gpt-5.6-terra", contextWindow: 300_000, maxTokens: 64_000 }), + }); + expect(rows[1].contextWindow).toBe(300_000); + expect(rows[1].maxTokens).toBe(64_000); + // Unresolvable default falls back to the shared unknown constants. + const fallback = projectQ10Models({ + models: [plain], + profiles: profiles as unknown as Map, + availableProfileIds: new Set(["opencodego"]), + }); + expect(fallback[1].contextWindow).toBeGreaterThan(0); + expect(fallback[1].maxTokens).toBeGreaterThan(0); + }); + + it("marks the active profile row current with inherit and suppresses the concrete current", () => { + const currentModel = model({ id: "plain" }); + const rows = projectQ10Models({ + models: [plain], + currentModel, + currentThinkingLevel: ThinkingLevel.Off, + profiles: profiles as unknown as Map, + availableProfileIds: new Set(["codex-eco"]), + activeProfile: "codex-eco", + }); + const currentRows = rows.filter(row => row.current); + expect(currentRows).toHaveLength(1); + expect(currentRows[0]).toMatchObject({ + provider: "gajae-code", + id: "codex-eco", + current: true, + currentThinkingLevel: ThinkingLevel.Inherit, + }); + }); + + it("keeps the active profile visible as the current readback even when unavailable", () => { + const rows = projectQ10Models({ + models: [plain], + profiles: profiles as unknown as Map, + availableProfileIds: new Set([]), + activeProfile: "codex-eco", + }); + expect(rows.map(row => `${row.provider}/${row.id}`)).toEqual(["test/plain", "gajae-code/codex-eco"]); + expect(rows[1].current).toBe(true); + }); + it("emits a bounded current fallback row when the active marker is absent from the profile map", () => { + const rows = projectQ10Models({ + models: [plain], + currentModel: { provider: "test", id: "plain" } as never, + currentThinkingLevel: ThinkingLevel.Off, + profiles: new Map(), + availableProfileIds: new Set(), + activeProfile: "codex-eco", + }); + const currentRows = rows.filter(row => row.current); + expect(currentRows).toHaveLength(1); + expect(currentRows[0]).toMatchObject({ + provider: "gajae-code", + id: "codex-eco", + name: "codex-eco", + reasoning: false, + thinking: { validLevels: [ThinkingLevel.Off] }, + current: true, + currentThinkingLevel: ThinkingLevel.Inherit, + }); + }); + + it("fails closed on an unknown availability join: never advertises non-current profiles", () => { + const rows = projectQ10Models({ + models: [plain], + profiles: profiles as unknown as Map, + // availableProfileIds intentionally absent: the join outcome is unknown. + }); + expect(rows.map(row => `${row.provider}/${row.id}`)).toEqual(["test/plain"]); + }); + + it("omits the facade entirely without profile inputs", () => { + const rows = projectQ10Models({ models: [plain] }); + expect(rows).toHaveLength(1); + expect(rows[0].provider).toBe("test"); + }); +}); diff --git a/packages/coding-agent/test/session-storage.test.ts b/packages/coding-agent/test/session-storage.test.ts index 95f0147142..bfb1ef0168 100644 --- a/packages/coding-agent/test/session-storage.test.ts +++ b/packages/coding-agent/test/session-storage.test.ts @@ -1161,6 +1161,31 @@ describe("replacement cleanup receipt reconcile TOCTOU resilience", () => { expect(fs.existsSync(path.join(root, "toctou-not-found"))).toBe(true); }); + it("continues reconcile when a canonical receipt disappears before first capture (ENOENT)", () => { + const predecessorPath = path.join(root, "predecessor"); + fs.writeFileSync(predecessorPath, "predecessor\n"); + const predecessor = snapshot(predecessorPath); + const { receipt } = publishCanonicalReceipt( + predecessor, + JSON.stringify({ arbitrary: "receipt contents are advisory" }), + ); + const realOpenSync = fs.openSync; + let receiptRemoved = false; + vi.spyOn(fs, "openSync").mockImplementation(((file, flags, mode) => { + if (!receiptRemoved && file === receipt) { + receiptRemoved = true; + fs.unlinkSync(receipt); + } + return realOpenSync(file, flags, mode); + }) as typeof fs.openSync); + + replay("toctou-canonical-enoent"); + + expect(receiptRemoved).toBe(true); + expect(fs.existsSync(receipt)).toBe(false); + expect(fs.existsSync(path.join(root, "toctou-canonical-enoent"))).toBe(true); + }); + it("continues reconcile when a legacy receipt disappears before first capture (ENOENT)", () => { const predecessorSeed = path.join(root, ".predecessor"); const predecessorContents = "predecessor\n"; @@ -1193,8 +1218,16 @@ describe("replacement cleanup receipt reconcile TOCTOU resilience", () => { JSON.stringify({ arbitrary: "second receipt contents are advisory" }), ); - // Remove the legacy receipt before reconcile runs — simulates concurrent disappearance. - fs.unlinkSync(receipt); + // Remove the legacy receipt after readdir returns but before capture opens it. + const realOpenSync = fs.openSync; + let receiptRemoved = false; + vi.spyOn(fs, "openSync").mockImplementation(((file, flags, mode) => { + if (!receiptRemoved && file === receipt) { + receiptRemoved = true; + fs.unlinkSync(receipt); + } + return realOpenSync(file, flags, mode); + }) as typeof fs.openSync); vi.spyOn(native, "exactUnlink").mockImplementation((pathname, expected) => { const stat = fs.lstatSync(pathname, { bigint: true }); @@ -1217,7 +1250,8 @@ describe("replacement cleanup receipt reconcile TOCTOU resilience", () => { return { ok: true, detachedPath }; }); - expect(() => replay("toctou-legacy-enoent")).not.toThrow(); + replay("toctou-legacy-enoent"); + expect(receiptRemoved).toBe(true); expect(fs.existsSync(path.join(root, "toctou-legacy-enoent"))).toBe(true); expect(fs.existsSync(secondReceipt)).toBe(false); diff --git a/scripts/ci-dev-affected.test.ts b/scripts/ci-dev-affected.test.ts index 5185591137..b61a236e32 100644 --- a/scripts/ci-dev-affected.test.ts +++ b/scripts/ci-dev-affected.test.ts @@ -1212,6 +1212,11 @@ test("tab-worker graph changes always include install-methods and are Darwin rel const tasks = targeted(["crates/pi-natives/src/path_identity.rs"]); expect(tasks.map(task => task.key)).toContain("test:packages/natives/test/path-identity-posix.test.ts"); }); + test("clean core changes select the clean script test alongside root tooling fallback", () => { + const keys = targeted(["scripts/clean-core.ts"]).map(task => task.key); + expect(keys).toContain("test:scripts/clean.test.ts"); + expect(keys).toContain("root-check"); + }); test("cache-eval evidence artifact adds its focused AI test without bypassing root fallback coverage", () => { const tasks = targeted(["artifacts/architecture-2383-eval.json"]); expect(tasks.map(task => task.key)).toEqual([ diff --git a/scripts/ci-dev-affected.ts b/scripts/ci-dev-affected.ts index 82c93735fe..a48b03c234 100755 --- a/scripts/ci-dev-affected.ts +++ b/scripts/ci-dev-affected.ts @@ -64,6 +64,7 @@ const BEHAVIORAL_OWNER_TESTS: Readonly> = { "artifacts/architecture-2383-eval.json": ["packages/ai/test/anthropic-cache-eval.integration.test.ts"], "crates/pi-natives/src/path_identity.rs": ["packages/natives/test/path-identity-posix.test.ts"], "packages/coding-agent/src/main.ts": ["packages/coding-agent/test/startup-update-contract.test.ts"], + "scripts/clean-core.ts": ["scripts/clean.test.ts"], }; export interface PackageManifest { diff --git a/scripts/clean-core.ts b/scripts/clean-core.ts new file mode 100644 index 0000000000..9ded108aa7 --- /dev/null +++ b/scripts/clean-core.ts @@ -0,0 +1,94 @@ +import * as path from "node:path"; + +export type CleanScope = "default" | "native"; + +export interface CleanPattern { + /** Glob relative to the repo root. Matches directories as well as files. */ + readonly glob: string; + readonly scope: CleanScope; +} + +export interface CleanOptions { + /** Also drop compiled native addons (`.node`), forcing a `build:native` rebuild. */ + readonly native?: boolean; +} + +/** + * Build output only. Everything listed here is either regenerated by a documented + * `bun run