From 1ff203b261ca32b9041c8e6659729e39e66750bc Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 9 Nov 2026 09:59:30 -0500 Subject: [PATCH 1/2] fix(acp): preserve explicit Claude models across reconnects --- packages/acp/src/provider.ts | 29 +- packages/acp/tests/adapter-protocol.test.ts | 154 ++++ packages/acp/tests/claude-reconnect.test.ts | 701 ++++++++++++++++++ .../acp/tests/fixtures/fake-claude-cli.cjs | 183 ++++- .../fixtures/wire-recording-acp-agent.cjs | 51 ++ packages/acp/tests/helpers.ts | 18 +- packages/acp/tests/loaded-copy.test.ts | 8 + packages/acp/tests/native-launch.test.ts | 14 + packages/acp/tests/provider.test.ts | 47 ++ packages/acp/vendor/acpx/MANIFEST.json | 8 +- packages/acp/vendor/acpx/PROVENANCE.md | 39 + .../generated/queue-owner-runtime-B0mPqOu4.js | 18 +- .../acp/vendor/acpx/generated/runtime.d.ts | 16 + packages/acp/vendor/acpx/generated/runtime.js | 50 +- packages/acp/vendor/adapters/MANIFEST.json | 4 +- packages/acp/vendor/adapters/PROVENANCE.md | 17 + .../acp/vendor/adapters/claude/adapter.patch | 95 ++- .../acp/vendor/adapters/claude/recipe.json | 14 +- packages/cli/src/headless/host.ts | 1 + packages/core/src/agent/provider-api.ts | 10 + packages/core/src/agent/session-setup.ts | 9 + .../core/tests/agent-session-setup.test.ts | 24 + packages/core/tests/root-provider.test.ts | 60 ++ scripts/tests/acpx-vendor.test.ts | 2 + specs/acp-client-spec.md | 52 +- specs/native-agent-session-launch-spec.md | 5 +- 26 files changed, 1574 insertions(+), 55 deletions(-) create mode 100644 packages/acp/tests/claude-reconnect.test.ts create mode 100644 packages/acp/tests/fixtures/wire-recording-acp-agent.cjs diff --git a/packages/acp/src/provider.ts b/packages/acp/src/provider.ts index dca8f85c4..7621f697f 100644 --- a/packages/acp/src/provider.ts +++ b/packages/acp/src/provider.ts @@ -516,6 +516,18 @@ type Prepared = }; /** The logical name a routed value carries, when it is one. */ +/** + * The runtime member a call carries when its use names a model. + * + * Present only when true: omission is the runtime's false, and nothing about + * the requested value travels with it. + */ +function explicitModel(configuration: SessionConfiguration | undefined): { + hasExplicitModel?: true; +} { + return configuration?.model === undefined ? {} : { hasExplicitModel: true }; +} + function asName(routed: string | Session | undefined): string | undefined { return typeof routed === "string" ? routed : undefined; } @@ -1803,6 +1815,11 @@ function* useAcpxProviderState( * acceptance rather than after this ensure. */ deferEstablished?: boolean; + /** + * Whether the use this ensure belongs to names a model, for the reconnect + * this ensure itself may perform. Never kept on the entry it returns. + */ + hasExplicitModel?: boolean; } function* ensureFromPrepared( @@ -1840,6 +1857,7 @@ function* useAcpxProviderState( ...(intent.materialization === true ? { materialization: "first-turn-acceptance" as const } : {}), + ...(intent.hasExplicitModel === true ? { hasExplicitModel: true } : {}), }, (handle, entry) => { // The exact value this provider already issued, when it issued one. @@ -2611,6 +2629,7 @@ function* useAcpxProviderState( agentName: string, entry: ManagedSession, requireWrite: boolean, + configuration?: SessionConfiguration, ): ConfigurationTarget { const runtime = entry.runtime.runtime; const readStatus = runtime.getStatus; @@ -2664,6 +2683,9 @@ function* useAcpxProviderState( handle: entry.handle, key: optionId, value, + // The same attempt's presence for its ordinary and restoring + // writes alike: either may be the one that reconnects. + ...explicitModel(configuration), }) .then(() => undefined); yield* until(pending); @@ -2718,7 +2740,7 @@ function* useAcpxProviderState( configuration: SessionConfiguration, ): Operation { const selectors = yield* applySessionConfiguration( - configurationTarget(agentName, entry, true), + configurationTarget(agentName, entry, true, configuration), configuration, ); return { @@ -2896,6 +2918,7 @@ function* useAcpxProviderState( ...(construction === undefined ? {} : { build: construction.build }), ...(resumeSessionId === undefined ? {} : { attachment: { resumeSessionId } }), state, + ...explicitModel(desired), materialization: constructing && adapterFor(agentName)?.acceptance === "first-turn-acceptance", deferEstablished: @@ -2951,6 +2974,7 @@ function* useAcpxProviderState( mode: "prompt", requestId, timeoutMs, + ...explicitModel(desired), ...(callbacks === undefined ? {} : { @@ -3643,6 +3667,7 @@ function* useAcpxProviderState( ...(bound === undefined ? {} : { build: bound.build }), attachment: { resumeSessionId: prepared.nativeSessionId }, state: "existing", + ...explicitModel(requested), }); } catch (error) { if (error instanceof AttachmentRefused) { @@ -3812,6 +3837,7 @@ function* useAcpxProviderState( text: plan.prompt, mode: "prompt", requestId: plan.requestId, + ...explicitModel(retainedConfiguration(prepared)), }); activeTurns.add(turn); @@ -4647,6 +4673,7 @@ function* useAcpxProviderState( ...(construction === undefined ? {} : { build: construction.build }), ...(resumeSessionId === undefined ? {} : { attachment: { resumeSessionId } }), state, + ...(setup?.hasExplicitModel === true ? { hasExplicitModel: true } : {}), }); // An existing session is reattached here: its route and its // identity already exist, so this is a conversation that can be diff --git a/packages/acp/tests/adapter-protocol.test.ts b/packages/acp/tests/adapter-protocol.test.ts index db6e67eea..9975bd37f 100644 --- a/packages/acp/tests/adapter-protocol.test.ts +++ b/packages/acp/tests/adapter-protocol.test.ts @@ -881,3 +881,157 @@ describe("Tier EA — the embedded adapters' prompt-response metadata", () => { expect(acceptances(adapter)).toEqual([]); }); }); + +/** The continuation marker a client sends when its use names a model (#1177). */ +const HAS_EXPLICIT_MODEL_META = "executablemd.has-explicit-model/v1"; + +/** + * A Claude adapter over the stateful fake, with a private global model + * preference and one conversation already running `running`. + */ +function* useResumable( + running: string, + environment: Record = {}, +): Operation<{ adapter: Adapter; sessionId: string; cwd: string }> { + const root = yield* until(mkdtemp(join(tmpdir(), "xmd ea model "))); + yield* ensure(() => rm(root, { recursive: true, force: true })); + const sessionId = "22222222-2222-4222-8222-222222222222"; + yield* until( + import("node:fs/promises").then(async (fs) => { + await fs.mkdir(join(root, "state")); + await fs.writeFile(join(root, "settings.json"), JSON.stringify({ model: "fable" })); + await fs.writeFile( + join(root, "state", `${sessionId}.json`), + JSON.stringify({ model: running }), + ); + }), + ); + const adapter = yield* useAdapter("claude", { + ...CLAUDE_ENV, + CLAUDE_CONFIG_DIR: root, + ANTHROPIC_MODEL: "", + FAKE_CLAUDE_STATE_DIR: join(root, "state"), + ...environment, + }); + yield* initialize(adapter); + // The conversation's own directory, so no project settings outside this + // case decide the global preference. + return { adapter, sessionId, cwd: root }; +} + +/** What a load/resume reports as the current model selection. */ +function reportedModel(response: Record): unknown { + const options = response["configOptions"]; + if (!Array.isArray(options)) { + return undefined; + } + return options.find((option) => option?.["id"] === "model")?.["currentValue"]; +} + +function* modelWrites(adapter: Adapter): Operation { + return (yield* readTextFile(adapter.log)) + .split("\n") + .filter((line) => line.includes('"control":"set_model"')) + .map((line) => JSON.parse(line).model); +} + +describe("Tier EA — the Claude adapter's resumed model under an explicit use (#1177)", () => { + it("EA15: a marked resume reports the exact running model and reasserts nothing", function* () { + for (const method of ["session/resume", "session/load"]) { + const { adapter, sessionId, cwd } = yield* useResumable("claude-fable-5"); + const response = yield* adapter.request(method, { + sessionId, + cwd, + mcpServers: [], + _meta: { [HAS_EXPLICIT_MODEL_META]: true }, + }); + expect(reportedModel(response)).toBe("claude-fable-5"); + expect(yield* modelWrites(adapter)).toEqual([]); + } + }); + + it("EA16: a different context hint is a different exact id, reported verbatim", function* () { + const { adapter, sessionId, cwd } = yield* useResumable("claude-fable-5[1m]", { + FAKE_CLAUDE_MODELS: JSON.stringify([ + { + value: "claude-fable-5", + resolvedModel: "claude-fable-5", + displayName: "A", + description: "", + }, + { + value: "claude-fable-5[1m]", + resolvedModel: "claude-fable-5[1m]", + displayName: "B", + description: "", + }, + ]), + }); + const response = yield* adapter.request("session/resume", { + sessionId, + cwd, + mcpServers: [], + _meta: { [HAS_EXPLICIT_MODEL_META]: true }, + }); + expect(reportedModel(response)).toBe("claude-fable-5[1m]"); + }); + + for (const [name, environment, refusal] of [ + ["absent", { FAKE_CLAUDE_CONTEXT_USAGE: "absent" }, "reported no model"], + ["failed", { FAKE_CLAUDE_CONTEXT_USAGE: "fail" }, "could not report the model it runs"], + [ + "unrepresentable", + { + FAKE_CLAUDE_MODELS: JSON.stringify([ + { + value: "default", + resolvedModel: "claude-fable-5-1", + displayName: "D", + description: "", + }, + { value: "fable", resolvedModel: "claude-fable-5-1", displayName: "F", description: "" }, + ]), + }, + "none of its offered models names exactly", + ], + ] as const) { + it(`EA17: a marked resume refuses a ${name} observation as an invalid request`, function* () { + const { adapter, sessionId, cwd } = yield* useResumable("fable", environment); + let failure = ""; + try { + yield* adapter.request("session/resume", { + sessionId, + cwd, + mcpServers: [], + _meta: { [HAS_EXPLICIT_MODEL_META]: true }, + }); + } catch (error) { + failure = String(error); + } + expect(failure).toContain(refusal); + // Not a missing conversation, which a client could replace. + expect(failure).toContain('"code":-32600'); + expect(yield* modelWrites(adapter)).toEqual([]); + expect(yield* readTextFile(adapter.log)).not.toContain('"type":"user"'); + }); + } + + it("EA18: without the marker, resume and new keep the existing model choice", function* () { + const { adapter, sessionId, cwd } = yield* useResumable("claude-fable-5"); + const resumed = yield* adapter.request("session/resume", { + sessionId, + cwd, + mcpServers: [], + }); + // The global preference is reasserted, exactly as before. + expect(reportedModel(resumed)).toBe("fable"); + expect(yield* modelWrites(adapter)).toEqual(["fable"]); + // A fresh session ignores the marker: there is nothing resumed to observe. + const fresh = yield* adapter.request("session/new", { + cwd, + mcpServers: [], + _meta: { [HAS_EXPLICIT_MODEL_META]: true }, + }); + expect(reportedModel(fresh)).toBe("fable"); + }); +}); diff --git a/packages/acp/tests/claude-reconnect.test.ts b/packages/acp/tests/claude-reconnect.test.ts new file mode 100644 index 000000000..868501465 --- /dev/null +++ b/packages/acp/tests/claude-reconnect.test.ts @@ -0,0 +1,701 @@ +/** + * Tier CR — an explicit Claude selection across cold reconnects (#1177). + * + * The provider gives up its handle after every owned operation, so the next one + * reconnects: a fresh adapter child resumes the same conversation. Two layers + * used to act on that resume before any prompt — the adapter reasserted the + * global model preference, and ACPX replayed the saved selection back — so each + * prompt cost a switch away and a switch back. + * + * Everything here runs the production artifacts: the real ACPX runtime, the + * packaged Claude adapter this build prepared, spawned as a real child, over the + * stateful fake CLI. The fake persists each conversation's model and effort the + * way a transcript does, logs every model write by name and records the model + * each prompt actually ran under, so what is asserted is the work the backend + * received — not which records were hidden. + */ +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { ensure, scoped, until } from "effection"; +import type { Operation } from "effection"; +import { ensureDir, readTextFile, rm, writeTextFile } from "@effectionx/fs"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createAcpRuntime, createSharedAcpRuntime } from "../src/acpx-runtime.ts"; +import type { + AcpRuntimeEnsureInput, + AcpRuntimeHandle, + AcpSessionRecord, +} from "../src/acpx-runtime.ts"; +import { createEmbeddedAdapters } from "../src/adapter-snapshots.ts"; +import type { EmbeddedAdapters } from "../src/adapter-snapshots.ts"; +import { Agent, sessionOf } from "@executablemd/core"; +import type { AgentLaunchCoordinator, Session, SessionConfiguration } from "@executablemd/core"; +import { openSessionRequest } from "../../core/src/agent/session-request.ts"; +import { createSessionSetupOwner } from "../../core/src/agent/session-setup.ts"; +import { createAcpxProvider } from "../src/provider.ts"; +import { makeCoordinator, makeRegistry, useFlatWorld } from "./helpers.ts"; + +const FAKE_CLAUDE = fileURLToPath(new URL("./fixtures/fake-claude-cli.cjs", import.meta.url)); + +/** The exact offered id a Sidekick selects, distinct from the global alias. */ +const EXPLICIT = "claude-fable-5"; +const ANOTHER = "claude-sonnet-5-5"; + +/** One entry the fake CLI logged. */ +interface Logged { + readonly event?: string; + readonly control?: string; + readonly session?: string; + readonly model?: string; + readonly effort?: string | null; + readonly resumed?: boolean; + readonly argv?: string[]; +} + +interface World { + readonly root: string; + readonly state: string; + readonly log: string; + readonly adapters: EmbeddedAdapters; + readonly records: Map; +} + +/** + * A private Claude home, conversation state and adapter install. + * + * `CLAUDE_CONFIG_DIR` points at this world's own settings, so the global model + * a case configures is this one's and never the user's. + */ +function* useWorld(globalModel?: string): Operation { + const root = yield* until(mkdtemp(join(tmpdir(), "xmd-cr-"))); + yield* ensure(() => rm(root, { recursive: true, force: true })); + const config = join(root, "config"); + const state = join(root, "state"); + yield* ensureDir(config); + yield* ensureDir(state); + yield* writeTextFile( + join(config, "settings.json"), + JSON.stringify(globalModel === undefined ? {} : { model: globalModel }), + ); + const adapters = createEmbeddedAdapters(join(root, "adapters")); + yield* adapters.materialize("claude"); + return { root, state, log: join(root, "cli.log"), adapters, records: new Map() }; +} + +function runtimeFor(world: World, env: Record = {}) { + return createAcpRuntime({ + cwd: world.root, + sessionStore: { + load: (id) => Promise.resolve(structuredClone(world.records.get(id))), + save: (record) => { + world.records.set(record.acpxRecordId, structuredClone(record)); + return Promise.resolve(); + }, + }, + agentRegistry: { resolve: () => world.adapters.command("claude"), list: () => ["claude"] }, + permissionMode: "approve-all", + nonInteractivePermissions: "deny", + agentProcessEnv: { + CLAUDE_CODE_EXECUTABLE: FAKE_CLAUDE, + CLAUDE_CONFIG_DIR: join(world.root, "config"), + FAKE_CLAUDE_LOG: world.log, + FAKE_CLAUDE_STATE_DIR: world.state, + ANTHROPIC_MODEL: "", + ...env, + }, + }); +} + +type Runtime = ReturnType; + +function* logged(world: World): Operation { + let text = ""; + try { + text = yield* readTextFile(world.log); + } catch { + return []; + } + return text + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line)); +} + +/** What a span of the log says, in the terms the acceptance is stated in. */ +function observed(entries: Logged[]) { + return { + modelWrites: entries.filter((entry) => entry.control === "set_model").map((e) => e.model), + prompts: entries + .filter((entry) => entry.event === "prompt") + .map((entry) => ({ session: entry.session, model: entry.model, effort: entry.effort })), + }; +} + +const KEY = "cr"; + +function input(world: World, hasExplicitModel?: boolean): AcpRuntimeEnsureInput { + return { + sessionKey: KEY, + agent: "claude", + mode: "persistent", + cwd: world.root, + ...(hasExplicitModel === undefined ? {} : { hasExplicitModel }), + }; +} + +/** One owned prompt the way the provider runs it: ensure, turn, release. */ +function* ownedTurn( + world: World, + runtime: Runtime, + text: string, + hasExplicitModel?: boolean, +): Operation<{ status: string }> { + return yield* scoped(function* () { + const handle = yield* until(runtime.ensureSession(input(world, hasExplicitModel))); + yield* ensure(() => + until(runtime.close({ handle, reason: "released after the owned prompt" })), + ); + const turn = runtime.startTurn({ + handle, + text, + mode: "prompt", + requestId: text, + ...(hasExplicitModel === undefined ? {} : { hasExplicitModel }), + }); + const result = yield* until(turn.result); + return { status: result.status }; + }); +} + +/** One owned configuration write: ensure, write, release. */ +function* ownedWrite( + world: World, + runtime: Runtime, + key: string, + value: string, + hasExplicitModel?: boolean, +): Operation { + return yield* scoped(function* () { + const handle = yield* until(runtime.ensureSession(input(world, hasExplicitModel))); + yield* ensure(() => until(runtime.close({ handle, reason: "released after the owned write" }))); + yield* until( + runtime.setConfigOption({ + handle, + key, + value, + ...(hasExplicitModel === undefined ? {} : { hasExplicitModel }), + }), + ); + return handle; + }); +} + +/** A conversation that exists, has run under `model` (and `effort`), and is released. */ +function* established( + world: World, + runtime: Runtime, + model: string, + effort?: string, +): Operation { + yield* ownedWrite(world, runtime, "model", model, true); + if (effort !== undefined) { + yield* ownedWrite(world, runtime, "effort", effort, true); + } + yield* ownedTurn(world, runtime, "first", true); +} + +describe( + "Tier CR — explicit Claude selection across cold reconnects", + { + sanitizeOps: false, + sanitizeResources: false, + }, + () => { + it("CR1: conflicting global settings cause no model write over repeated cold prompts", function* () { + const world = yield* useWorld("fable"); + yield* scoped(function* () { + const runtime = runtimeFor(world); + yield* ensure(() => until(runtime.shutdown())); + yield* established(world, runtime, EXPLICIT, "high"); + const before = (yield* logged(world)).length; + // The one necessary write is the initial selection. + expect(observed(yield* logged(world)).modelWrites).toEqual([EXPLICIT]); + + for (const text of ["second", "third", "fourth"]) { + expect(yield* ownedTurn(world, runtime, text, true)).toEqual({ status: "completed" }); + } + + const entries = (yield* logged(world)).slice(before); + const { modelWrites, prompts } = observed(entries); + // Neither the global alias nor the saved selection is written back. + expect(modelWrites).toEqual([]); + const session = prompts[0]?.session; + expect(session).toBeDefined(); + // The same conversation, model and effort on every prompt. + expect(prompts).toEqual([ + { session, model: EXPLICIT, effort: "high" }, + { session, model: EXPLICIT, effort: "high" }, + { session, model: EXPLICIT, effort: "high" }, + ]); + // Every prompt was a cold reconnect: a fresh child resumed the same + // conversation, and each child closed before the next began. + const starts = entries.filter((entry) => entry.event === "started"); + expect(starts.map((entry) => [entry.session, entry.resumed])).toEqual([ + [session, true], + [session, true], + [session, true], + ]); + expect(entries.filter((entry) => entry.event === "exit").length).toBe(3); + }); + }); + + it("CR2: a cold non-model write and a genuine change keep exactly the necessary writes", function* () { + const world = yield* useWorld("fable"); + yield* scoped(function* () { + const runtime = runtimeFor(world); + yield* ensure(() => until(runtime.shutdown())); + yield* established(world, runtime, EXPLICIT); + yield* ownedWrite(world, runtime, "effort", "high", true); + const before = (yield* logged(world)).length; + + // A cold control reconnect for another setting writes no model. + yield* ownedWrite(world, runtime, "effort", "low", true); + expect(observed((yield* logged(world)).slice(before)).modelWrites).toEqual([]); + + // A genuine change is written once, exactly, and verified in status + // before the next prompt — which then runs under it with no write. + const changed = yield* ownedWrite(world, runtime, "model", ANOTHER, true); + const status = yield* until(runtime.getStatus({ handle: changed })); + expect(status.models?.currentModelId).toBe(ANOTHER); + // The new model's effort choices are refreshed, so the configured + // effort is written for it, as session configuration does. + yield* ownedWrite(world, runtime, "effort", "low", true); + yield* ownedTurn(world, runtime, "after the change", true); + const after = observed((yield* logged(world)).slice(before)); + expect(after.modelWrites).toEqual([ANOTHER]); + expect(after.prompts.map((prompt) => [prompt.model, prompt.effort])).toEqual([ + [ANOTHER, "low"], + ]); + }); + }); + + it("CR3: an unflagged use keeps the provider-default behavior, even after a flagged one", function* () { + const world = yield* useWorld("fable"); + yield* scoped(function* () { + const runtime = runtimeFor(world); + yield* ensure(() => until(runtime.shutdown())); + // Never configured: the global preference governs, as it always has, + // and each resume reasserts it. + yield* ownedTurn(world, runtime, "first"); + yield* ownedTurn(world, runtime, "second"); + const unconfigured = observed(yield* logged(world)); + expect(unconfigured.prompts.map((prompt) => prompt.model)).toEqual([ + "claude-fable-5-1", + "claude-fable-5-1", + ]); + expect(unconfigured.modelWrites).toEqual(["fable"]); + }); + + const flagged = yield* useWorld("fable"); + yield* scoped(function* () { + const runtime = runtimeFor(flagged); + yield* ensure(() => until(runtime.shutdown())); + // A flagged use leaves nothing behind for a later unflagged one: that + // reconnect reasserts the global preference and the saved selection is + // replayed over it, exactly as before. + yield* established(flagged, runtime, EXPLICIT); + const before = (yield* logged(flagged)).length; + yield* ownedTurn(flagged, runtime, "unflagged"); + const after = observed((yield* logged(flagged)).slice(before)); + expect(after.modelWrites).toEqual(["fable", EXPLICIT]); + expect(after.prompts.map((prompt) => prompt.model)).toEqual([EXPLICIT]); + }); + }); + + it("CR4: the presence fact reaches only the reconnect and is never retained", function* () { + const world = yield* useWorld("fable"); + yield* scoped(function* () { + const runtime = runtimeFor(world); + yield* ensure(() => until(runtime.shutdown())); + yield* established(world, runtime, EXPLICIT); + yield* ownedTurn(world, runtime, "again", true); + const durable = JSON.stringify([...world.records.values()]); + expect(durable).not.toContain("has-explicit-model"); + expect(durable).not.toContain("hasExplicitModel"); + // It never reaches Claude Code's own options or arguments either. + const launches = (yield* logged(world)).filter((entry) => entry.argv !== undefined); + expect(JSON.stringify(launches)).not.toContain("has-explicit-model"); + const handle = yield* until(runtime.ensureSession(input(world, true))); + expect(JSON.stringify(handle)).not.toContain("xplicit"); + yield* until(runtime.close({ handle, reason: "inspected" })); + + // A malformed fact refuses before any effect. + const starts = (yield* logged(world)).length; + const malformed = { ...input(world), hasExplicitModel: "yes" as unknown as boolean }; + const refusal = yield* until( + runtime.ensureSession(malformed).then( + () => "", + (error: Error) => error.message, + ), + ); + expect(refusal).toContain("hasExplicitModel must be a boolean"); + expect((yield* logged(world)).length).toBe(starts); + }); + }); + + it("CR5: an external change while released is observed and the exact selection applied", function* () { + const world = yield* useWorld("fable"); + yield* scoped(function* () { + const runtime = runtimeFor(world); + yield* ensure(() => until(runtime.shutdown())); + yield* established(world, runtime, EXPLICIT); + const session = observed(yield* logged(world)).prompts[0]?.session; + // A native owner changed the model while ACP held nothing. + const conversation = join(world.state, `${encodeURIComponent(session ?? "")}.json`); + const persisted = JSON.parse(yield* readTextFile(conversation)); + yield* writeTextFile(conversation, JSON.stringify({ ...persisted, model: ANOTHER })); + + const before = (yield* logged(world)).length; + yield* ownedTurn(world, runtime, "after native", true); + const after = observed((yield* logged(world)).slice(before)); + // The reconnect saw the real change, so the selection is written back + // once — exactly — and the prompt runs under it in the same conversation. + expect(after.modelWrites).toEqual([EXPLICIT]); + expect(after.prompts).toEqual([{ session, model: EXPLICIT, effort: null }]); + }); + }); + + it("CR6: an unrepresentable or unreadable resumed model refuses before any prompt", function* () { + const world = yield* useWorld(); + const aliasOnly = JSON.stringify([ + { + value: "default", + resolvedModel: "claude-fable-5-1", + displayName: "Default", + description: "", + }, + { + value: "fable", + resolvedModel: "claude-fable-5-1", + displayName: "Fable", + description: "", + }, + ]); + yield* scoped(function* () { + const runtime = runtimeFor(world, { FAKE_CLAUDE_MODELS: aliasOnly }); + yield* ensure(() => until(runtime.shutdown())); + yield* ownedWrite(world, runtime, "model", "fable", true); + // The backend reports the concrete model behind the alias. Nothing offered + // names it exactly, so a use naming its own model refuses rather than + // inventing that row or reading it as the alias. + const refused = yield* refusalOfTurn(world, runtime, "flagged", true); + expect(refused).toContain("claude-fable-5-1"); + expect(observed(yield* logged(world)).prompts).toEqual([]); + // Without the fact, the existing behavior stands. + expect(yield* ownedTurn(world, runtime, "unflagged")).toEqual({ status: "completed" }); + }); + + const unreadable = yield* useWorld(); + yield* scoped(function* () { + const runtime = runtimeFor(unreadable); + yield* ensure(() => until(runtime.shutdown())); + yield* established(unreadable, runtime, EXPLICIT); + const failing = runtimeFor(unreadable, { FAKE_CLAUDE_CONTEXT_USAGE: "fail" }); + yield* ensure(() => until(failing.shutdown())); + const prompts = observed(yield* logged(unreadable)).prompts.length; + const refused = yield* refusalOfTurn(unreadable, failing, "flagged", true); + expect(refused).toContain("could not report the model it runs"); + expect(observed(yield* logged(unreadable)).prompts.length).toBe(prompts); + }); + }); + }, +); + +const WIRE_AGENT = fileURLToPath( + new URL("./fixtures/wire-recording-acp-agent.cjs", import.meta.url), +); + +describe( + "Tier CR — the continuation marker on the wire", + { + sanitizeOps: false, + sanitizeResources: false, + }, + () => { + it("CR8: only a Claude load or resume for a flagged call carries the marker", function* () { + const root = yield* until(mkdtemp(join(tmpdir(), "xmd-cr-wire-"))); + yield* ensure(() => rm(root, { recursive: true, force: true })); + // The same recording agent under a path ACPX recognizes as Claude's + // adapter, and under one it does not. + const claudeDirectory = join(root, "claude-agent-acp"); + yield* ensureDir(claudeDirectory); + const claudeAgent = join(claudeDirectory, "agent.cjs"); + yield* writeTextFile(claudeAgent, yield* readTextFile(WIRE_AGENT)); + const wire = join(root, "wire.log"); + + function* journey(command: string): Operation> { + yield* rm(wire, { force: true }); + const records = new Map(); + const runtime = createAcpRuntime({ + cwd: root, + sessionStore: { + load: (id) => Promise.resolve(structuredClone(records.get(id))), + save: (record) => { + records.set(record.acpxRecordId, structuredClone(record)); + return Promise.resolve(); + }, + }, + agentRegistry: { resolve: () => command, list: () => ["agent"] }, + permissionMode: "approve-all", + nonInteractivePermissions: "deny", + agentProcessEnv: { WIRE_LOG: wire }, + }); + yield* ensure(() => until(runtime.shutdown())); + const ensureInput = (hasExplicitModel?: boolean): AcpRuntimeEnsureInput => ({ + sessionKey: "wire", + agent: "agent", + mode: "persistent", + cwd: root, + ...(hasExplicitModel === undefined ? {} : { hasExplicitModel }), + }); + // Created under a flagged call: session/new never carries it. + const created = yield* until(runtime.ensureSession(ensureInput(true))); + yield* until(runtime.close({ handle: created, reason: "release" })); + for (const hasExplicitModel of [true, false, undefined, true]) { + const handle = yield* until(runtime.ensureSession(ensureInput(hasExplicitModel))); + const turn = runtime.startTurn({ + handle, + text: "hello", + mode: "prompt", + requestId: `turn-${String(hasExplicitModel)}`, + ...(hasExplicitModel === undefined ? {} : { hasExplicitModel }), + }); + yield* until(turn.result); + yield* until(runtime.close({ handle, reason: "release" })); + } + return (yield* readTextFile(wire)) + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line)) + .filter((entry) => + ["session/new", "session/load", "session/resume"].includes(entry.method), + ) + .map((entry) => ({ + method: entry.method, + meta: entry.params?._meta?.["executablemd.has-explicit-model/v1"], + })); + } + + expect(yield* journey(`node ${JSON.stringify(claudeAgent)}`)).toEqual([ + { method: "session/new", meta: undefined }, + { method: "session/load", meta: true }, + { method: "session/load", meta: undefined }, + { method: "session/load", meta: undefined }, + { method: "session/load", meta: true }, + ]); + // Another adapter never hears it. + expect( + (yield* journey(`node ${JSON.stringify(WIRE_AGENT)}`)).map((entry) => entry.meta), + ).toEqual([undefined, undefined, undefined, undefined, undefined]); + }); + + it("CR9: a shared runtime refuses a flagged call before any effect", function* () { + const shared = createSharedAcpRuntime({ cwd: tmpdir(), permissionMode: "deny-all" }); + const refusal = (body: () => unknown) => { + try { + body(); + return ""; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }; + const handle: AcpRuntimeHandle = { + sessionKey: "shared", + backend: "acpx", + runtimeSessionName: "shared", + }; + expect( + refusal(() => + shared.ensureSession({ + sessionKey: "shared", + agent: "claude", + mode: "persistent", + hasExplicitModel: true, + }), + ), + ).toContain("not supported by shared sessions"); + expect( + refusal(() => + shared.startTurn({ + handle, + text: "x", + mode: "prompt", + requestId: "x", + hasExplicitModel: true, + }), + ), + ).toContain("not supported by shared sessions"); + const write = yield* until( + shared.setConfigOption({ handle, key: "model", value: "m", hasExplicitModel: true }).then( + () => "", + (error: Error) => error.message, + ), + ); + expect(write).toContain("not supported by shared sessions"); + }); + }, +); + +/** + * The coordinator core delivers, reduced to what a configured prompt reaches: + * the genuine setup owner settles and reads uses, and nothing is launched. + */ +function deliveredCoordinator(): AgentLaunchCoordinator { + const owner = createSessionSetupOwner(); + return { + sessionSetup: (request) => owner.setup(request), + sessionUse: (routed) => owner.read(routed), + checkpoint: () => {}, + // deno-lint-ignore require-yield + *perform(): Operation { + throw new Error("this journey performs no launch"); + }, + // deno-lint-ignore require-yield + *refuse(): Operation { + throw new Error("this journey refuses no launch"); + }, + }; +} + +/** One configured `` use and one prompt in it, through the provider. */ +function* configuredPrompt( + configuration: SessionConfiguration | undefined, + text: string, +): Operation { + const issuance = openSessionRequest(undefined, "sidekick"); + issuance.configure(configuration); + try { + const use = yield* Agent.operations.session(issuance.request); + yield* scoped(function* () { + const subscription = yield* yield* Agent.operations.prompt(text, { session: use }); + let next = yield* subscription.next(); + while (!next.done) { + next = yield* subscription.next(); + } + }); + return use; + } finally { + issuance.close(); + } +} + +describe( + "Tier CR — the production provider journey", + { + sanitizeOps: false, + sanitizeResources: false, + }, + () => { + it("CR7: configured Sidekick prompts reconnect under the exact model with no model writes", function* () { + const world = yield* useWorld("fable"); + const ownership = makeCoordinator(); + yield* scoped(function* () { + yield* useFlatWorld(world.root); + const runtimes: Runtime[] = []; + yield* ensure(function* () { + for (const runtime of runtimes) { + yield* until(runtime.shutdown()); + } + }); + const factory = createAcpxProvider({ + createRuntime: () => { + const runtime = runtimeFor(world); + runtimes.push(runtime); + return runtime; + }, + sessionStore: { + load: (id) => Promise.resolve(structuredClone(world.records.get(id))), + save: (record) => { + world.records.set(record.acpxRecordId, structuredClone(record)); + return Promise.resolve(); + }, + }, + agentRegistry: makeRegistry({ claude: world.adapters.command("claude") }), + advertiseNativeLaunch: ["claude"], + advertiseClientNativeAttachment: [], + nativeAdapters: { + claude: { + launcher: "claude", + identity: "provider-returned", + resume: (nativeSessionId) => ["claude", "--resume", nativeSessionId], + }, + }, + coordinator: ownership.coordinator, + }); + yield* factory( + { defaultAgent: "claude", permissionMode: "deny-all" }, + deliveredCoordinator(), + ); + + const selection = { model: EXPLICIT, effort: "high" }; + const first = yield* configuredPrompt(selection, "first"); + const before = (yield* logged(world)).length; + for (const text of ["second", "third", "fourth"]) { + const use = yield* configuredPrompt(selection, text); + expect(sessionOf(use)).toBe(sessionOf(first)); + } + const repeated = (yield* logged(world)).slice(before); + const { modelWrites, prompts } = observed(repeated); + expect(modelWrites).toEqual([]); + const session = prompts[0]?.session; + expect(prompts).toEqual([ + { session, model: EXPLICIT, effort: "high" }, + { session, model: EXPLICIT, effort: "high" }, + { session, model: EXPLICIT, effort: "high" }, + ]); + // Each use reconnected through a fresh child and released it: the + // eager resolution and the prompt each own one, and every one closed. + const starts = repeated.filter((entry) => entry.event === "started").length; + expect(starts).toBeGreaterThanOrEqual(3); + expect(repeated.filter((entry) => entry.event === "exit").length).toBe(starts); + expect(ownership.events.filter((event) => event === "released-active")).toEqual([]); + + // A genuine change is written once and is what the next prompt runs. + const change = (yield* logged(world)).length; + yield* configuredPrompt({ model: ANOTHER, effort: "high" }, "changed"); + const changed = observed((yield* logged(world)).slice(change)); + expect(changed.modelWrites).toEqual([ANOTHER]); + expect(changed.prompts).toEqual([{ session, model: ANOTHER, effort: "high" }]); + }); + }); + }, +); + +/** A flagged turn's refusal, with nothing prompted and the handle released. */ +function* refusalOfTurn( + world: World, + runtime: Runtime, + text: string, + hasExplicitModel: boolean, +): Operation { + return yield* scoped(function* () { + const handle = yield* until(runtime.ensureSession(input(world, hasExplicitModel))); + yield* ensure(() => until(runtime.close({ handle, reason: "refused" }))); + try { + const turn = runtime.startTurn({ + handle, + text, + mode: "prompt", + requestId: text, + hasExplicitModel, + }); + const result = yield* until(turn.result); + return result.status === "completed" ? "" : JSON.stringify(result); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }); +} diff --git a/packages/acp/tests/fixtures/fake-claude-cli.cjs b/packages/acp/tests/fixtures/fake-claude-cli.cjs index c9ff73584..1f834f31f 100755 --- a/packages/acp/tests/fixtures/fake-claude-cli.cjs +++ b/packages/acp/tests/fixtures/fake-claude-cli.cjs @@ -17,6 +17,16 @@ const crypto = require("node:crypto"); const { spawn } = require("node:child_process"); const LOG = process.env.FAKE_CLAUDE_LOG; +/** + * Where a stateful case keeps each conversation's model and effort. + * + * Opt-in. Without it every control answers with the fixed `fake-model` + * catalog and nothing persists, which is what the existing cases assume. With + * it, a resumed process continues the model its conversation last ran — the way + * the CLI restores a transcript — and a cold reconnect can be observed against + * what the previous process actually ran. + */ +const STATE_DIR = process.env.FAKE_CLAUDE_STATE_DIR; /** The session this CLI was launched for, as the adapter named it. */ const SESSION = @@ -29,6 +39,97 @@ const SESSION = /** What a test expects back, built from that session and nothing else. */ const UUID = `uuid:${SESSION}`; +const RESUMED = process.argv.some((argument) => argument.startsWith("--resume=")); + +/** The picker a stateful case offers: aliases resolve, exact ids name themselves. */ +const CATALOG = STATE_DIR + ? JSON.parse( + process.env.FAKE_CLAUDE_MODELS ?? + JSON.stringify([ + { + value: "default", + resolvedModel: "claude-fable-5-1", + displayName: "Default", + description: "the account default", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high"], + }, + { + value: "fable", + resolvedModel: "claude-fable-5-1", + displayName: "Fable", + description: "the latest Fable", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high"], + }, + { + value: "claude-fable-5", + resolvedModel: "claude-fable-5", + displayName: "Fable 5", + description: "an exact model", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high"], + }, + { + value: "claude-sonnet-5-5", + resolvedModel: "claude-sonnet-5-5", + displayName: "Sonnet 5.5", + description: "another exact model", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high"], + }, + ]), + ) + : [ + { + value: "fake-model", + resolvedModel: "fake-model", + displayName: "Fake", + description: "a stand-in", + isDefault: true, + }, + ]; + +function statePath() { + return require("node:path").join(STATE_DIR, `${encodeURIComponent(SESSION)}.json`); +} + +function argumentAfter(flag) { + const index = process.argv.indexOf(flag); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +/** + * The selection this process runs under. + * + * A resumed conversation continues what it last ran, whatever this launch was + * asked for; a new one starts from `--model`, or the account default. + */ +const state = (() => { + if (!STATE_DIR) { + return { model: "fake-model", effort: undefined }; + } + if (RESUMED && fs.existsSync(statePath())) { + return JSON.parse(fs.readFileSync(statePath(), "utf8")); + } + return { model: argumentAfter("--model") ?? "default", effort: argumentAfter("--effort") }; +})(); + +function persist() { + if (STATE_DIR) { + fs.writeFileSync(statePath(), JSON.stringify(state)); + } +} + +/** The model a request actually reaches: an alias's resolution, or the exact id. */ +function running() { + if (!STATE_DIR) { + return "fake-model"; + } + const entry = CATALOG.find((model) => model.value === state.model); + return entry?.resolvedModel ?? state.model; +} + function log(entry) { if (LOG) { fs.appendFileSync(LOG, JSON.stringify(entry) + "\n"); @@ -44,6 +145,10 @@ if (process.env.FAKE_CLAUDE_EXIT_STDERR) { return; } log({ executable: process.argv[1], argv: process.argv.slice(2) }); +if (STATE_DIR && !process.argv.includes("--version")) { + persist(); + log({ event: "started", session: SESSION, resumed: RESUMED, model: state.model }); +} if (process.argv.includes("--version")) { console.log("Fake Claude Code 0.0.0"); return; @@ -56,7 +161,7 @@ emit({ session_id: SESSION, tools: [], mcp_servers: [], - model: "fake-model", + model: running(), permissionMode: "default", slash_commands: [], apiKeySource: "none", @@ -135,6 +240,63 @@ function usage() { }; } +/** + * Answer the controls a stateful case observes, or leave the request alone. + * + * Model and effort writes change this conversation's persisted selection and + * are logged by name, so a case can count exactly which ones a reconnect sent. + * `get_context_usage` reports what the conversation actually runs. + */ +function answerStateful(message) { + const request = message.request ?? {}; + const succeed = (response) => + emit({ + type: "control_response", + response: { subtype: "success", request_id: message.request_id, response }, + }); + if (request.subtype === "set_model") { + state.model = request.model ?? "default"; + persist(); + log({ control: "set_model", session: SESSION, model: state.model }); + succeed({}); + return true; + } + if (request.subtype === "apply_flag_settings") { + if (request.settings && Object.hasOwn(request.settings, "effortLevel")) { + state.effort = request.settings.effortLevel ?? undefined; + persist(); + log({ control: "effort", session: SESSION, effort: state.effort ?? null }); + } + succeed({}); + return true; + } + if (request.subtype === "get_context_usage") { + log({ control: "get_context_usage", session: SESSION, model: running() }); + if (process.env.FAKE_CLAUDE_CONTEXT_USAGE === "fail") { + emit({ + type: "control_response", + response: { + subtype: "error", + request_id: message.request_id, + error: "context usage is unavailable", + }, + }); + return true; + } + succeed({ + categories: [], + totalTokens: 0, + maxTokens: 200000, + rawMaxTokens: 200000, + percentage: 0, + gridRows: [], + model: process.env.FAKE_CLAUDE_CONTEXT_USAGE === "absent" ? undefined : running(), + }); + return true; + } + return false; +} + let buffer = ""; process.stdin.on("data", (chunk) => { buffer += chunk.toString("utf8"); @@ -180,6 +342,9 @@ process.stdin.on("data", (chunk) => { // Only Query failure releases this lookup, so setup cannot outrun the contradiction. continue; } + if (STATE_DIR && answerStateful(message)) { + continue; + } emit({ type: "control_response", response: { @@ -190,15 +355,7 @@ process.stdin.on("data", (chunk) => { agents: [], output_style: "default", available_output_styles: ["default"], - models: [ - { - value: "fake-model", - resolvedModel: "fake-model", - displayName: "Fake", - description: "a stand-in", - isDefault: true, - }, - ], + models: CATALOG, account: { email: "fake@example.test", organization: null, subscription: null }, hooks_applied: true, }, @@ -213,6 +370,10 @@ process.stdin.on("data", (chunk) => { continue; } + if (STATE_DIR) { + log({ event: "prompt", session: SESSION, model: running(), effort: state.effort ?? null }); + } + // Echo the user message back, the way the CLI replays a queued turn. emit({ ...message, session_id: SESSION, isReplay: true, parent_tool_use_id: null }); @@ -227,7 +388,7 @@ process.stdin.on("data", (chunk) => { id: "msg_fake", type: "message", role: "assistant", - model: "fake-model", + model: running(), content: [{ type: "text", text: "answered", citations: null }], stop_reason: "end_turn", stop_sequence: null, diff --git a/packages/acp/tests/fixtures/wire-recording-acp-agent.cjs b/packages/acp/tests/fixtures/wire-recording-acp-agent.cjs new file mode 100644 index 000000000..704a1c12f --- /dev/null +++ b/packages/acp/tests/fixtures/wire-recording-acp-agent.cjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +/** + * A minimal ACP agent that records every request it receives. + * + * Enough of the protocol for ACPX to create, load and prompt one session; each + * request's method and params are appended to `WIRE_LOG`, so a case can state + * exactly which metadata reached the agent on `session/new` and `session/load`. + */ +const fs = require("node:fs"); + +const LOG = process.env.WIRE_LOG; +let buffer = ""; +let sessions = 0; + +function reply(id, result) { + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n"); +} + +process.stdin.on("data", (chunk) => { + buffer += chunk.toString("utf8"); + let index = buffer.indexOf("\n"); + while (index >= 0) { + const line = buffer.slice(0, index); + buffer = buffer.slice(index + 1); + index = buffer.indexOf("\n"); + if (!line.trim()) { + continue; + } + const message = JSON.parse(line); + if (typeof message.method !== "string" || message.id === undefined) { + continue; + } + if (LOG) { + fs.appendFileSync( + LOG, + JSON.stringify({ method: message.method, params: message.params }) + "\n", + ); + } + if (message.method === "initialize") { + reply(message.id, { protocolVersion: 1, agentCapabilities: { loadSession: true } }); + } else if (message.method === "session/new") { + sessions += 1; + reply(message.id, { sessionId: `wire-${process.pid}-${sessions}` }); + } else if (message.method === "session/prompt") { + reply(message.id, { stopReason: "end_turn" }); + } else { + reply(message.id, {}); + } + } +}); +process.stdin.on("end", () => process.exit(0)); diff --git a/packages/acp/tests/helpers.ts b/packages/acp/tests/helpers.ts index e8c0b357c..a7f09c47c 100644 --- a/packages/acp/tests/helpers.ts +++ b/packages/acp/tests/helpers.ts @@ -124,6 +124,13 @@ export interface FakeRuntimeHarness { doctorCalls: number; ensureCalls: AcpRuntimeEnsureInput[]; turns: FakeTurn[]; + /** + * Whether each ensure, turn and write said its use names a model, in order. + * + * ` `, with `-` for an omitted member, so a case can + * state that the presence travelled with exactly the calls it belongs to. + */ + presence: string[]; closeCalls: AcpRuntimeHandle[]; /** * Every close, whole. @@ -368,6 +375,7 @@ export function createFakeRuntime(): FakeRuntimeHarness { doctorCalls: 0, ensureCalls: [], turns: [], + presence: [], handleIds: [], startedTurns(count) { return { @@ -407,6 +415,7 @@ export function createFakeRuntime(): FakeRuntimeHarness { } harness.activity?.("ensure"); harness.ensureCalls.push(input); + harness.presence.push(`ensure ${input.hasExplicitModel ?? "-"}`); const handleId = `${input.sessionKey}#${harness.handleIds.length}`; harness.handleIds.push(handleId); // Deferred materialization: the record occupies the key and asserts @@ -461,6 +470,7 @@ export function createFakeRuntime(): FakeRuntimeHarness { return gate ? run(() => gate).then(answer) : Promise.resolve(answer()); }, startTurn(input) { + harness.presence.push(`turn ${input.hasExplicitModel ?? "-"}`); harness.activity?.("turn"); const script = scripted.shift() ?? {}; const recordId = input.handle.acpxRecordId ?? input.handle.sessionKey; @@ -619,7 +629,13 @@ export function createFakeRuntime(): FakeRuntimeHarness { // Promise boundary, exactly as a manual turn's release does. return gate ? run(() => gate).then(answer) : Promise.resolve(answer()); }, - setConfigOption(input: { handle: AcpRuntimeHandle; key: string; value: string }) { + setConfigOption(input: { + handle: AcpRuntimeHandle; + key: string; + value: string; + hasExplicitModel?: boolean; + }) { + harness.presence.push(`set ${input.key} ${input.hasExplicitModel ?? "-"}`); harness.activity?.(`set ${input.key}=${input.value}`); harness.configCalls.push(`set ${input.key}=${input.value}`); const failure = harness.writeFailures?.[input.key]; diff --git a/packages/acp/tests/loaded-copy.test.ts b/packages/acp/tests/loaded-copy.test.ts index 97c8f4076..6f9416246 100644 --- a/packages/acp/tests/loaded-copy.test.ts +++ b/packages/acp/tests/loaded-copy.test.ts @@ -161,8 +161,15 @@ function sessionSetupOf(value: unknown): AgentSessionSetup { throw new Error("the other copy answered a session setup with nothing that could settle it"); } const identity = Reflect.get(value, "sessionIdentity"); + const hasExplicitModel = Reflect.get(value, "hasExplicitModel"); + if (typeof hasExplicitModel !== "boolean") { + throw new Error( + "the other copy answered a session setup that does not say whether it names a model", + ); + } return { ...(typeof identity === "string" ? { sessionIdentity: identity } : {}), + hasExplicitModel, *activate(provider, agentCommand, configuration) { // Across the same seam as `finish`, for the same reason: the authority // that decides whether this provider may work lives in the copy that @@ -329,6 +336,7 @@ function foreignCoordinator(owner: ForeignOwner, log: SessionSetupLog): AgentLau const setup = owner.setup(request); const identity = setup.sessionIdentity; const observed: AgentSessionSetup = { + hasExplicitModel: setup.hasExplicitModel, activate: (provider, agentCommand, configuration) => setup.activate(provider, agentCommand, configuration), finish: (session, state) => setup.finish(session, state), diff --git a/packages/acp/tests/native-launch.test.ts b/packages/acp/tests/native-launch.test.ts index d31df25d1..0a5409b56 100644 --- a/packages/acp/tests/native-launch.test.ts +++ b/packages/acp/tests/native-launch.test.ts @@ -295,6 +295,7 @@ function traceCoordinator(trace: Trace): AgentLaunchCoordinator { ...(requested.sessionIdentity === undefined ? {} : { sessionIdentity: requested.sessionIdentity }), + hasExplicitModel: requested.configuration?.model !== undefined, // This trace's setups carry no retained conversation, so there is // nothing to be held to and activation only records what the provider // declared it was about to build under. It is still required: a @@ -5085,6 +5086,9 @@ describe("Tier NC — configured native launch", () => { // native UI was handed is the one this preparation named. expect(harness.ensureCalls.length).toBe(1); expect(trace.launches[0]?.command).toContain(prepared.nativeSessionId); + // A fresh creation resumes nothing; both writes belong to this use, which + // names a model. + expect(harness.presence).toEqual(["ensure -", "set model true", "set effort true"]); }); }); @@ -5207,6 +5211,13 @@ describe("Tier NC — configured native launch", () => { "spawn", "exited", ]); + // The owed turn is spent under the launch's own configured use. + expect(harness.presence).toEqual([ + "ensure -", + "set model true", + "set effort true", + "turn true", + ]); }); }); @@ -5319,6 +5330,8 @@ describe("Tier NR — configured incomplete replay", () => { expect(harness.ensureCalls[0]?.resumeSessionId).toBe(`agent-session:${SESSION_KEY}`); expect(harness.turns.length).toBe(0); expect(trace.launches[0]?.command).toContain(`agent-session:${SESSION_KEY}`); + // The reacquisition resumes under the retained configuration's presence. + expect(harness.presence).toEqual(["ensure true", "set model true", "set effort true"]); }); }); @@ -5553,6 +5566,7 @@ describe("Tier NR — configured incomplete replay", () => { expect(trace.order).toEqual(["prepared", "detached", "spawn", "exited"]); expect(harness.ensureCalls).toEqual([]); expect(harness.configCalls).toEqual([]); + expect(harness.presence).toEqual([]); }); }); diff --git a/packages/acp/tests/provider.test.ts b/packages/acp/tests/provider.test.ts index 520b0dfd2..bdb869c99 100644 --- a/packages/acp/tests/provider.test.ts +++ b/packages/acp/tests/provider.test.ts @@ -897,6 +897,7 @@ function stubCoordinator(): CoordinatorLog { const setup = owner.setup(request); return { ...(setup.sessionIdentity === undefined ? {} : { sessionIdentity: setup.sessionIdentity }), + hasExplicitModel: setup.hasExplicitModel, // Through the genuine owner, like `finish`: this suite's provider work // is authorized by the same operation a conversation's requirement // would reach, so a provider that settled a setup it never activated @@ -2929,6 +2930,52 @@ describe("Tier SE — reconfiguring an owned session", () => { }); }); + it("SE3: each reconnecting call carries only its own use's model presence", function* () { + const harness = configured(createFakeRuntime()); + const ownership = makeCoordinator(); + yield* scoped(function* () { + const coordinator = yield* installOwnedProvider(harness, ownership); + // A first configured prompt: the ensure, both writes and the turn all + // belong to a use that names a model. + yield* configuredPrompt(coordinator, { model: "gpt-5.4-mini", effort: "high" }); + expect(harness.presence).toEqual([ + "ensure true", + "set model true", + "set effort true", + "turn true", + ]); + + // A later unconfigured resolution and prompt inherit nothing from the + // earlier use: the eager reattachment and the prompt's own both omit it. + harness.presence = []; + const plain = yield* Agent.operations.session("review"); + yield* collectPrompt("plain", { session: plain }); + expect(harness.presence).toEqual(["ensure -", "ensure -", "turn -"]); + + // Eager existing resolution forwards the setup's presence before its + // ensure, then configures through finish; effort-only names no model. + harness.presence = []; + yield* configuredSession(coordinator, "review", { effort: "low" }); + expect(harness.presence).toEqual(["ensure -", "set effort -"]); + harness.presence = []; + yield* configuredSession(coordinator, "review", { model: "gpt-5.4" }); + expect(harness.presence).toEqual(["ensure true", "set model true"]); + + // A restoring write belongs to the attempt that made it. + harness.presence = []; + harness.writeFailures = { effort: new Error("the agent refused that effort level") }; + yield* refusalOf(() => + configuredSession(coordinator, "review", { model: "gpt-5.4-mini", effort: "high" }), + ); + expect(harness.presence).toEqual([ + "ensure true", + "set model true", + "set effort true", + "set model true", + ]); + }); + }); + it("SE2: a cancelled reconfiguration gives them back too", function* () { const harness = configured(createFakeRuntime()); const ownership = makeCoordinator(); diff --git a/packages/acp/vendor/acpx/MANIFEST.json b/packages/acp/vendor/acpx/MANIFEST.json index 549e6f70b..8a3b0a263 100644 --- a/packages/acp/vendor/acpx/MANIFEST.json +++ b/packages/acp/vendor/acpx/MANIFEST.json @@ -31,7 +31,7 @@ { "path": "PROVENANCE.md", "kind": "provenance", - "sha256": "dd0f2c28ee7aabf490a54e7779ac9863fde229f84296a4a051646afe40cf7be6" + "sha256": "e5330c09a3b1320768e898154c49d7f1e89743f2c974a60bf3271e1e7f09b824" }, { "path": "generated/agent-registry-Cz9fD9H1.js", @@ -56,17 +56,17 @@ { "path": "generated/queue-owner-runtime-B0mPqOu4.js", "kind": "generated", - "sha256": "4f564876f38b978472f1e1a8f133db53a155f33614b193564a34d5e5b12069bc" + "sha256": "bb84664a2dc8b222695f524932617d986b75cd4fba136020d1dfabef45d6a5a4" }, { "path": "generated/runtime.d.ts", "kind": "generated", - "sha256": "d7dad6ef20bfbf94867dcd90751ffad58d58bc62cb12469ee5f1524283aa9c87" + "sha256": "27264c3e9ef507558cef778a4757a17807fc970ea94cea4830aec089e91957da" }, { "path": "generated/runtime.js", "kind": "generated", - "sha256": "1daede0ebbdf54d2814ce594d695e8b6f1ade183506e09fce799b1bd574b9f4b" + "sha256": "dbf126f17993045a2a1735b16534c1fec373bb0ab6d9c4e96000b314a7616fa0" }, { "path": "generated/session-options-BlnP3WAc.d.ts", diff --git a/packages/acp/vendor/acpx/PROVENANCE.md b/packages/acp/vendor/acpx/PROVENANCE.md index 9efbce143..0d3862c64 100644 --- a/packages/acp/vendor/acpx/PROVENANCE.md +++ b/packages/acp/vendor/acpx/PROVENANCE.md @@ -161,6 +161,45 @@ Remove the correction only when a published runtime exposes the same per-turn identities and selected readings on every ending and passes the same deterministic consumers and offline vendor verification. +## Explicit-model continuation (#1177) + +A provider releases its handle after each owned operation, so the next one +reconnects and the resumed adapter initializes before any client configuration +can run. The released runtime has no per-call input for that initialization: +`sessionOptions` are persisted creation inputs, and a compatible ensure may +return without constructing the client a later prompt or control reconnects +through. + +The `hasExplicitModel` seam adds one optional boolean to the in-process ensure, +turn and `setConfigOption` inputs. The runtime refuses a non-boolean, and the +shared runtime refuses `true` before any effect. The manager hands the value to +the clients that call constructs — ensure creation, the cold turn client and +the cold control client — and to nothing else: no record, saved option, handle +encoding or retained owner holds it, and a retained live client is reused +unchanged. `AcpClient.continuationMeta()` merges +`"executablemd.has-explicit-model/v1": true` into Claude `session/load` and +`session/resume` metadata for a `true` call; `session/new` and other adapters +are unchanged. `runtime.js` carries validation and forwarding, +`queue-owner-runtime-B0mPqOu4.js` the metadata, and `runtime.d.ts` the +declarations. + +## Exact-model replay (#1177) + +On reconnect, `replayDesiredModel` re-applied the saved model selection whenever +the resumed session advertised models, even when the session already reported +that model. The `XMD exact-model replay:` seam returns the existing no-replay +result for a resumed session whose freshly reported `currentModelId` is +exactly the saved desired id, after the existing support validation and before +any write. Equality with a resolved alias counterpart does not qualify, and a +fresh replacement session replays as before. A different id keeps the existing +write, acknowledgement, record update and failure path; mode and other +config-option replay are unchanged. + +Remove both seams only when a published runtime supplies a per-call +continuation input and an equal-model replay skip that pass the same +reconnect regressions (`packages/acp/tests/claude-reconnect.test.ts`) and +offline vendor verification. + ## Released environment seam (#566) The released runtime snapshots `agentProcessEnv`, delivers it to every child and diff --git a/packages/acp/vendor/acpx/generated/queue-owner-runtime-B0mPqOu4.js b/packages/acp/vendor/acpx/generated/queue-owner-runtime-B0mPqOu4.js index 50a35e118..cc3ea797e 100644 --- a/packages/acp/vendor/acpx/generated/queue-owner-runtime-B0mPqOu4.js +++ b/packages/acp/vendor/acpx/generated/queue-owner-runtime-B0mPqOu4.js @@ -3065,6 +3065,16 @@ var AcpClient = class { const { command, args } = resolveAgentCommandParts(this.options.agentCommand, this.options.agentArgv); return buildClaudeCodeOptionsMeta(this.options.sessionOptions, isClaudeAcpCommand(command, args)); } + /** hasExplicitModel: this client's own call names a model; only Claude load/resume hears it, never session/new. */ + continuationMeta() { + const meta = this.sessionMeta(); + const { command, args } = resolveAgentCommandParts(this.options.agentCommand, this.options.agentArgv); + if (this.options.hasExplicitModel !== true || !isClaudeAcpCommand(command, args)) return meta; + return { + ...meta, + "executablemd.has-explicit-model/v1": true + }; + } async createSession(cwd = this.options.cwd, authority) { assertControlAuthority(authority); const connection = this.getConnection(); @@ -3117,7 +3127,8 @@ var AcpClient = class { sessionId, cwd: sessionCwd, mcpServers: this.options.mcpServers ?? [], - _meta: this.sessionMeta() + // hasExplicitModel + _meta: this.continuationMeta() }), options.authority); await this.waitForSessionUpdateDrain(options.replayIdleMs ?? REPLAY_IDLE_MS, options.replayDrainTimeoutMs ?? REPLAY_DRAIN_TIMEOUT_MS); } finally { @@ -3136,7 +3147,8 @@ var AcpClient = class { sessionId, cwd: sessionCwd, mcpServers: this.options.mcpServers ?? [], - _meta: this.sessionMeta() + // hasExplicitModel + _meta: this.continuationMeta() }), authority); this.loadedSessionId = sessionId; const result = toReconnectedSessionResult(response); @@ -4993,6 +5005,8 @@ async function replayDesiredModel(params) { models: params.models, agentCommand: params.record.agentCommand }); + // XMD exact-model replay: a resumed session already reporting the identical saved id needs no write. + if (!params.createdFreshSession && params.models.currentModelId === params.desiredModelId) return { replayed: false }; const response = await withTimeout(params.client.setSessionModel(params.sessionId, params.desiredModelId, params.models, params.replay.authority), params.timeoutMs); params.replay.acknowledged = true; params.record.acpx = applyModelSelection(params.record.acpx, params.desiredModelId, response, resolvedModelId); diff --git a/packages/acp/vendor/acpx/generated/runtime.d.ts b/packages/acp/vendor/acpx/generated/runtime.d.ts index 9b1564b05..9ab226719 100644 --- a/packages/acp/vendor/acpx/generated/runtime.d.ts +++ b/packages/acp/vendor/acpx/generated/runtime.d.ts @@ -85,6 +85,14 @@ type AcpRuntimeEnsureInput = { * different sessionKey or prepareFreshSession on the prior record first. */ sessionOptions?: SessionAgentOptions; + /** + * Whether this call belongs to a use that explicitly specifies a model. + * + * Applies only to this call's Claude reconnects. Omission is false. It carries + * no requested value and does not apply or verify configuration. Shared + * runtimes refuse true; a non-boolean refuses. + */ + hasExplicitModel?: boolean; }; type AcpRuntimeTurnAttachment = { /** @@ -113,6 +121,8 @@ type AcpRuntimeTurnInput = AcpControlAuthority & { onPermissionRequest?: AcpPermissionHandler; /** Handles ACP elicitation requests owned by this prompt turn. */ onElicitation?: AcpElicitationHandler; + /** Whether this turn's use names a model; see `AcpRuntimeEnsureInput.hasExplicitModel`. */ + hasExplicitModel?: boolean; }; /** Caller-owned snapshot; local mutations do not change runtime capabilities. */ type AcpRuntimeCapabilities = { @@ -411,6 +421,8 @@ interface AcpRuntime { handle: AcpRuntimeHandle; key: string; value: string; + /** Whether this write's use names a model; see `AcpRuntimeEnsureInput.hasExplicitModel`. */ + hasExplicitModel?: boolean; }): Promise; doctor?(): Promise; cancel(input: { @@ -786,6 +798,8 @@ export declare class SharedAcpRuntime { handle: AcpRuntimeHandle; key: string; value: string; + /** Whether this write's use names a model; see `AcpRuntimeEnsureInput.hasExplicitModel`. */ + hasExplicitModel?: boolean; }): Promise; watchSession(input: { handle: AcpRuntimeHandle; @@ -870,6 +884,8 @@ export declare class AcpxRuntime implements AcpxRuntimeLike { handle: AcpRuntimeHandle; key: string; value: string; + /** Whether this write's use names a model; see `AcpRuntimeEnsureInput.hasExplicitModel`. */ + hasExplicitModel?: boolean; }): Promise; cancel(input: { handle: AcpRuntimeHandle; diff --git a/packages/acp/vendor/acpx/generated/runtime.js b/packages/acp/vendor/acpx/generated/runtime.js index 8739a3bd1..927349918 100644 --- a/packages/acp/vendor/acpx/generated/runtime.js +++ b/packages/acp/vendor/acpx/generated/runtime.js @@ -1387,7 +1387,7 @@ var AcpRuntimeManager = class { terminal: this.options.terminal }; } - async withRuntimeControlSession(record, sessionMode, run, replayOptions, authority) { + async withRuntimeControlSession(record, sessionMode, run, replayOptions, authority, hasExplicitModel = false) { await this.retrySessionCleanup(record.acpxRecordId); assertControlAuthority(authority); const owner = await this.readRetainedSessionOwner(record, { consume: false }); @@ -1424,6 +1424,7 @@ var AcpRuntimeManager = class { saveRecord: async () => checkpoint.checkpoint(), createClient: (options) => controlClient = this.createClient({ ...options, + hasExplicitModel, processLifecycle: this.options.processLifecycle, processLaunchScope: { kind: "runtime-session", @@ -1611,7 +1612,8 @@ var AcpRuntimeManager = class { sessionKey: input.sessionKey }, verbose: this.options.verbose, - sessionOptions: input.sessionOptions + sessionOptions: input.sessionOptions, + hasExplicitModel: input.hasExplicitModel === true }); const owner = this.createSessionOwner({ client, @@ -1857,7 +1859,8 @@ var AcpRuntimeManager = class { async prepareRuntimeTurnWithOwnership(task) { const { record, retainedOwner, conversation, acpxState, promptMessageId } = await this.prepareRuntimeTurnState(task); try { - const client = retainedOwner?.client ?? this.createTurnClient(record); + // hasExplicitModel: a retained live client is not reinitialized for a later use. + const client = retainedOwner?.client ?? this.createTurnClient(record, task.input.hasExplicitModel === true); const owner = this.resolveRuntimeTurnOwner(task, record, client, retainedOwner); const turn = this.createRunningRuntimeTurn({ record, @@ -1965,7 +1968,7 @@ var AcpRuntimeManager = class { owner.bufferSessionUpdates = false; this.drainPendingSessionUpdates(owner); } - createTurnClient(record) { + createTurnClient(record, hasExplicitModel = false) { return this.createClient({ agentCommand: record.agentCommand, agentArgv: record.agentArgv, @@ -1986,7 +1989,8 @@ var AcpRuntimeManager = class { sessionKey: record.name ?? record.acpxRecordId }, verbose: this.options.verbose, - sessionOptions: sessionOptionsFromRecord(record) + sessionOptions: sessionOptionsFromRecord(record), + hasExplicitModel }); } buildRuntimeTurnController(task, turn) { @@ -2253,11 +2257,11 @@ var AcpRuntimeManager = class { connectedRecord.acpx = applyModelSelection(connectedRecord.acpx, model, response, resolvedModelId); }, { replacingConfigOption: { key: "model" } }, authority); } - async setConfigOption(handle, key, value, sessionMode = "persistent", authority) { + async setConfigOption(handle, key, value, sessionMode = "persistent", authority, hasExplicitModel = false) { const recordId = handle.acpxRecordId ?? handle.sessionKey; - return await this.withManagerLock(this.runtimeOperationLocks, recordId, async () => this.setConfigOptionWithOwnership(handle, key, value, sessionMode, authority), authority); + return await this.withManagerLock(this.runtimeOperationLocks, recordId, async () => this.setConfigOptionWithOwnership(handle, key, value, sessionMode, authority, hasExplicitModel), authority); } - async setConfigOptionWithOwnership(handle, key, value, sessionMode, authority) { + async setConfigOptionWithOwnership(handle, key, value, sessionMode, authority, hasExplicitModel) { const record = await this.requireRecord(handle.acpxRecordId ?? handle.sessionKey); assertControlAuthority(authority); const controller = this.activeControllers.get(record.acpxRecordId); @@ -2280,7 +2284,7 @@ var AcpRuntimeManager = class { }, { replacingConfigOption: { key, resolve: (connectedRecord) => resolveSupportedConfigOptionId(connectedRecord, key) - } }, authority); + } }, authority, hasExplicitModel); } async cancel(handle) { await this.activeControllers.get(handle.acpxRecordId ?? handle.sessionKey)?.requestCancelActivePrompt(); @@ -2504,6 +2508,16 @@ const SHARED_BACKEND = "acpx-shared"; function invalidOption(message) { throw new AcpRuntimeError("ACP_INVALID_RUNTIME_OPTION", message); } +/** hasExplicitModel: a call's own presence fact; omission is false and nothing else is accepted. */ +function hasExplicitModelOf(input) { + const hasExplicitModel = input?.hasExplicitModel; + if (hasExplicitModel !== void 0 && typeof hasExplicitModel !== "boolean") invalidOption("hasExplicitModel must be a boolean when supplied."); + return hasExplicitModel === true; +} +/** hasExplicitModel reaches only in-process Claude reconnects; shared sessions refuse it before any effect. */ +function refuseSharedHasExplicitModel(input) { + if (hasExplicitModelOf(input)) invalidOption("hasExplicitModel belongs to the in-process runtime and is not supported by shared sessions."); +} function sharedHandle(record) { return { backend: SHARED_BACKEND, @@ -2599,6 +2613,7 @@ var SharedAcpRuntime = class { } ensureSession(input) { this.assertOpen(); + refuseSharedHasExplicitModel(input); if (input.mode !== "persistent") invalidOption("Shared sessions require persistent mode; use the in-process runtime for oneshot sessions."); const session = this.sessionOptions(input); return this.track(ensureSession({ @@ -2616,6 +2631,7 @@ var SharedAcpRuntime = class { } startTurn(input) { this.assertOpen(); + refuseSharedHasExplicitModel(input); if (input.mode !== "prompt" || input.assertActive || input.onPermissionRequest || input.onElicitation) invalidOption("Shared turns support prompt mode and the client's static permission policy; in-process callbacks and steering are unsupported."); if (!input.requestId.trim()) invalidOption("A shared turn requires a requestId."); const sessionId = sharedRecordId(input.handle); @@ -2763,6 +2779,7 @@ var SharedAcpRuntime = class { })); } async setConfigOption(input) { + refuseSharedHasExplicitModel(input); return (await this.dispatchControl(input, "session/set_config_option", async (sessionId, assertAuthority) => { const configId = resolveSupportedConfigOptionId(await resolveSessionRecord(sessionId), input.key); assertAuthority(); @@ -2864,6 +2881,7 @@ var AcpxRuntime = class { } async ensureSession(input) { const { sessionName, agent } = this.resolveSessionIdentity(input); + const hasExplicitModel = hasExplicitModelOf(input); const record = await (await this.getManager()).ensureSession({ sessionKey: sessionName, agent, @@ -2871,7 +2889,8 @@ var AcpxRuntime = class { cwd: input.cwd ?? this.options.cwd, resumeSessionId: input.resumeSessionId, sessionOptions: input.sessionOptions, - materialization: input.materialization + materialization: input.materialization, + hasExplicitModel }); return this.createSessionHandle({ ...input, @@ -2901,6 +2920,7 @@ var AcpxRuntime = class { } startTurn(input) { const { handle, state } = this.resolveManagerHandle(input.handle); + const hasExplicitModel = hasExplicitModelOf(input); const turnPromise = this.getManager().then((manager) => manager.startTurn({ handle, text: input.text, @@ -2912,7 +2932,8 @@ var AcpxRuntime = class { signal: input.signal, assertActive: input.assertActive, onElicitation: input.onElicitation, - onPermissionRequest: input.onPermissionRequest + onPermissionRequest: input.onPermissionRequest, + hasExplicitModel })); turnPromise.catch(() => {}); const sessionMaterialization = turnPromise.then((turn) => turn.materialized); @@ -2939,6 +2960,7 @@ var AcpxRuntime = class { } async *runTurn(input) { const { handle, state } = this.resolveManagerHandle(input.handle); + const hasExplicitModel = hasExplicitModelOf(input); yield* (await this.getManager()).runTurn({ handle, text: input.text, @@ -2950,7 +2972,8 @@ var AcpxRuntime = class { signal: input.signal, assertActive: input.assertActive, onElicitation: input.onElicitation, - onPermissionRequest: input.onPermissionRequest + onPermissionRequest: input.onPermissionRequest, + hasExplicitModel }); } async getCapabilities(input) { @@ -2972,7 +2995,8 @@ var AcpxRuntime = class { } async setConfigOption(input) { const { handle, state } = this.resolveManagerHandle(input.handle); - return await (await this.getManager()).setConfigOption(handle, input.key, input.value, state.mode, input); + const hasExplicitModel = hasExplicitModelOf(input); + return await (await this.getManager()).setConfigOption(handle, input.key, input.value, state.mode, input, hasExplicitModel); } async cancel(input) { const { handle } = this.resolveManagerHandle(input.handle); diff --git a/packages/acp/vendor/adapters/MANIFEST.json b/packages/acp/vendor/adapters/MANIFEST.json index d3f200599..d2b91f9ae 100644 --- a/packages/acp/vendor/adapters/MANIFEST.json +++ b/packages/acp/vendor/adapters/MANIFEST.json @@ -37,8 +37,8 @@ "license": "Apache-2.0", "contract": "_meta.claudeCode.assistantMessageUuid", "materializationContract": "session_info_update _meta[executablemd.session-materialization/v1]", - "byteLength": 2004653, - "sha256": "773db68c6b94fcf34540d387017247d6893f42359337a167a42baf636eef2685", + "byteLength": 2006701, + "sha256": "7bb5296c72adb4268f8dc9ed484b53fd2aa6b1b16ab30868d7b8dfb63501257e", "files": [ "package/LICENSE", "package/README.md", diff --git a/packages/acp/vendor/adapters/PROVENANCE.md b/packages/acp/vendor/adapters/PROVENANCE.md index 9044fe30c..d4f8c97de 100644 --- a/packages/acp/vendor/adapters/PROVENANCE.md +++ b/packages/acp/vendor/adapters/PROVENANCE.md @@ -234,3 +234,20 @@ import reaches it through `dist/claude-sdk.js`. Compiler declarations remain a verified build input. They, the pristine SDK and the transform are absent from the runtime archive; repository preparation reproduces the shipped bytes from pinned public inputs instead. + +### Claude resumed model under an explicit use + +The readable `claude/adapter.patch` extends producer +`e3a6ceb7a50e1151fb49322ea83d331a4c8b74ca` with one local change for #1177; no +upstream commit carries it. A `session/load` or `session/resume` whose `_meta` +carries `"executablemd.has-explicit-model/v1": true` belongs to a client use +that names its own model. On that branch only, `createSession` skips +`getAvailableModels`' environment/settings reassertion and calls +`observeResumedModel`: the backend's `getContextUsage` model is the current +model selection verbatim, with the offered catalog and allowlist unchanged. A +failed or empty report, or one no offered model names exactly, refuses with an +invalid-request error before any prompt, so a client never reads it as a missing +conversation to replace. The marker is never passed into SDK options. Fresh, +unmarked and effort-only initialization keep the existing priority. The recipe +records the changed patch, `src/acp-agent.ts` postimage, `dist/acp-agent.js` +runtime and output hashes; source, SDK and compiler pins are unchanged. diff --git a/packages/acp/vendor/adapters/claude/adapter.patch b/packages/acp/vendor/adapters/claude/adapter.patch index 7aa9acb2a..e9f2828c6 100644 --- a/packages/acp/vendor/adapters/claude/adapter.patch +++ b/packages/acp/vendor/adapters/claude/adapter.patch @@ -1,5 +1,5 @@ diff --git a/src/acp-agent.ts b/src/acp-agent.ts -index e32f603e7cb85544a9ebf108489fcf98932d14a2..39fe9c4a1670b9738866646acda578792aff8444 100644 +index e32f603e7cb85544a9ebf108489fcf98932d14a2..96a816b3d4321ead8c2790f617b87804b711d5c4 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -81,7 +81,7 @@ import { @@ -339,7 +339,7 @@ index e32f603e7cb85544a9ebf108489fcf98932d14a2..39fe9c4a1670b9738866646acda57879 }; } -@@ -6058,572 +6236,748 @@ export class ClaudeAcpAgent { +@@ -6058,572 +6236,751 @@ export class ClaudeAcpAgent { } } @@ -1166,6 +1166,8 @@ index e32f603e7cb85544a9ebf108489fcf98932d14a2..39fe9c4a1670b9738866646acda57879 - throw RequestError.authRequired( - undefined, - "This integration does not support using claude.ai subscriptions.", +- ); +- } + // Apply user's `availableModels` allowlist from settings.json before any + // downstream model handling. The SDK only enforces this allowlist in its + // own UI, not in `initializationResult.models`, so we filter here to keep @@ -1181,15 +1183,17 @@ index e32f603e7cb85544a9ebf108489fcf98932d14a2..39fe9c4a1670b9738866646acda57879 + ) + : initializationResult.models; + -+ const { modelState: models, resumedContextWindow } = await getAvailableModels( -+ q, -+ allowedModels, -+ initializationResult.models, -+ settingsManager, -+ this.logger, -+ creationOpts.resume !== undefined, - ); -- } ++ const { modelState: models, resumedContextWindow } = ++ creationOpts.resume !== undefined && continuesExplicitModel(params._meta) ++ ? await observeResumedModel(q, allowedModels) ++ : await getAvailableModels( ++ q, ++ allowedModels, ++ initializationResult.models, ++ settingsManager, ++ this.logger, ++ creationOpts.resume !== undefined, ++ ); - // Apply user's `availableModels` allowlist from settings.json before any - // downstream model handling. The SDK only enforces this allowlist in its @@ -1616,6 +1620,75 @@ index e32f603e7cb85544a9ebf108489fcf98932d14a2..39fe9c4a1670b9738866646acda57879 } /** +@@ -7574,6 +7931,68 @@ async function readResumedLiveModel( + } + } + ++/** The continuation marker a client sends when its use names a model. */ ++const HAS_EXPLICIT_MODEL_META = "executablemd.has-explicit-model/v1"; ++ ++/** Whether this load/resume belongs to a use that explicitly names a model. */ ++function continuesExplicitModel(meta: unknown): boolean { ++ return ( ++ typeof meta === "object" && ++ meta !== null && ++ (meta as Record)[HAS_EXPLICIT_MODEL_META] === true ++ ); ++} ++ ++/** ++ * The model a resumed session runs, reported exactly, for a use that names its ++ * own model. ++ * ++ * Nothing is reasserted: the client applies its own exact selection next, so an ++ * environment or settings preference written here would only be written back. ++ * The backend's report is the answer verbatim — no picker matching, alias, hint ++ * reconstruction or default row stands in for it — and one this session's ++ * offered models cannot represent refuses rather than inventing a selectable ++ * entry. A failed or empty report refuses too, before any prompt. ++ * ++ * Refused as an invalid request so a client does not mistake it for a missing ++ * conversation and create a replacement. ++ */ ++async function observeResumedModel( ++ query: Query, ++ models: ModelInfo[], ++): Promise<{ modelState: SessionModelState; resumedContextWindow: number | null }> { ++ let usage: Awaited>; ++ try { ++ usage = await query.getContextUsage(); ++ } catch (error) { ++ throw RequestError.invalidRequest( ++ undefined, ++ `the resumed session could not report the model it runs: ${error instanceof Error ? error.message : String(error)}`, ++ ); ++ } ++ const observed = usage.model; ++ if (typeof observed !== "string" || observed.length === 0) { ++ throw RequestError.invalidRequest(undefined, "the resumed session reported no model"); ++ } ++ if (!models.some((model) => model.value === observed)) { ++ throw RequestError.invalidRequest( ++ { model: observed }, ++ `the resumed session runs "${observed}", which none of its offered models names exactly`, ++ ); ++ } ++ return { ++ modelState: { ++ availableModels: models.map((model) => ({ ++ modelId: model.value, ++ name: model.displayName, ++ description: model.description, ++ })), ++ currentModelId: observed, ++ }, ++ resumedContextWindow: usage.rawMaxTokens > 0 ? usage.rawMaxTokens : null, ++ }; ++} ++ + async function getAvailableModels( + query: Query, + models: ModelInfo[], diff --git a/src/claude-sdk.ts b/src/claude-sdk.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b7d1f0b92a2ee959d336959bc93ffe349c69e98 diff --git a/packages/acp/vendor/adapters/claude/recipe.json b/packages/acp/vendor/adapters/claude/recipe.json index dbac8a6bf..1356de6bb 100644 --- a/packages/acp/vendor/adapters/claude/recipe.json +++ b/packages/acp/vendor/adapters/claude/recipe.json @@ -32,7 +32,7 @@ "node": ">=14.17" } }, - "patchSha256": "873f04c4549c05fcfd1c11d0efbcd3392c9d1e7702e29528c48d75a97316b94b", + "patchSha256": "c263bcb53645128b81d8cd1cb4bf2d38b2efdee07546222101e58cdea1dd2cbd", "transformSha256": "d20ae1bc7335768bdf417831cdac359cb40cfd786adb6bfd78b3808253d8030b", "preimages": { "src/acp-agent.ts": "a52b723182828025341f5111109f4b902beaae22589b77d52f9236f279ef2a1c", @@ -46,7 +46,7 @@ "src/tools.ts": "7f5affb80dc728e6c6ec6378871a4a67f284382c5b66b9a96fd30f453b7c3905" }, "postimages": { - "src/acp-agent.ts": "ac7019cddbd7557ce74a6d93c64b94637162a462ef07a34bfebb7ca625dc9e71", + "src/acp-agent.ts": "74727abef9a9f44916ca126bcf2a65f399fea07dbab587df008e440960e83a39", "src/claude-sdk.ts": "b80d272dea9583ecae6ca2c86477cae7f7f9186b8cefdbb1ad1fb6c05e3ddc9a", "src/file-change-audit.ts": "14a9ad8488c5793cb49172ac86d7fd481cd2b23da6589b2275ff3a429eb9b324", "src/index.ts": "c6f3d6074749e0ff466e10cc32e1798be8da0b271e8ba201a6ecb330badf1261", @@ -57,7 +57,7 @@ "src/tools.ts": "ff8c9d821ce82462a659bc5d5c85299f9419072daa14c52208fa31fed5945e7c" }, "runtime": { - "dist/acp-agent.js": "5890b00c9e0ad1744cb0efe94d08d10469ef7db460c4133bb74a95044c7157be", + "dist/acp-agent.js": "1b3831c6f3f96a1548d9a9311c9e50d99478ffd98d5fced1070f11aaa667e241", "dist/claude-sdk.js": "b80d272dea9583ecae6ca2c86477cae7f7f9186b8cefdbb1ad1fb6c05e3ddc9a", "dist/clear-context-coordinator.js": "854e5fccdbb3343c57ddf158fb8a1dd5d34d7b13483032437c2fed6c898154a6", "dist/permissions/effects.js": "f2d2ce76f2012b6062ad565b079c2078ae4dbf9282f505876ff38313c521745b", @@ -126,9 +126,9 @@ "package.json" ], "expected": { - "sha256": "773db68c6b94fcf34540d387017247d6893f42359337a167a42baf636eef2685", - "byteLength": 2004653, - "moduleSha256": "117a2bf6356bb7ec567fa6b3e35266db38dda095ac9f12252bc5ca88b3c39444" + "sha256": "7bb5296c72adb4268f8dc9ed484b53fd2aa6b1b16ab30868d7b8dfb63501257e", + "byteLength": 2006701, + "moduleSha256": "f63fb8d356622bb09750168d4682878fe2126f5b033abdd3c5acfafe5ee06ce9" }, "metadata": { "name": "@agentclientprotocol/claude-agent-acp", @@ -164,5 +164,5 @@ "zod": "^3.25.0 || ^4.0.0" } }, - "inputsSha256": "23cbd3e280c82dadb7b1bdc04f5ed84b9ef65ef6332d49ff84e79b64a61e15ae" + "inputsSha256": "00a6a5eaac8b937d546f14ecdc6ae80e6b4ba815abda33c640c4777d93541f0a" } diff --git a/packages/cli/src/headless/host.ts b/packages/cli/src/headless/host.ts index b9110da2b..85c748f02 100644 --- a/packages/cli/src/headless/host.ts +++ b/packages/cli/src/headless/host.ts @@ -458,6 +458,7 @@ export function* useHeadlessChildren( setups.set(identity, slot); return { sessionIdentity: identity, + hasExplicitModel: genuine.hasExplicitModel, *activate(provider, agent, configuration) { setups.delete(identity); const accepted = yield* genuine.activate(provider, agent, configuration); diff --git a/packages/core/src/agent/provider-api.ts b/packages/core/src/agent/provider-api.ts index a9763994e..3961169df 100644 --- a/packages/core/src/agent/provider-api.ts +++ b/packages/core/src/agent/provider-api.ts @@ -220,6 +220,7 @@ function sessionSetupOf(value: unknown): AgentSessionSetup { const activate = Reflect.get(value, "activate"); const finish = Reflect.get(value, "finish"); const sessionIdentity = Reflect.get(value, "sessionIdentity"); + const hasExplicitModel = Reflect.get(value, "hasExplicitModel"); if (typeof finish !== "function") { throw new AgentProviderInstallError( "the live agent provider installation answered a session setup that settles nothing", @@ -236,8 +237,17 @@ function sessionSetupOf(value: unknown): AgentSessionSetup { "conversation it names", ); } + if (typeof hasExplicitModel !== "boolean") { + // No inferred default: a provider that guessed would initialize a resumed + // conversation under the wrong model preference. + throw new AgentProviderInstallError( + "the live agent provider installation answered a session setup that does not say whether " + + "its request names a model", + ); + } return { ...(typeof sessionIdentity === "string" ? { sessionIdentity } : {}), + hasExplicitModel, // Bound to the handle that answered, like `finish`: both are closures over // one issuance in the copy of core that opened it, and calling either with // any other receiver would reach a different copy's state or none. diff --git a/packages/core/src/agent/session-setup.ts b/packages/core/src/agent/session-setup.ts index 0d78515f8..a0eec77cc 100644 --- a/packages/core/src/agent/session-setup.ts +++ b/packages/core/src/agent/session-setup.ts @@ -66,6 +66,14 @@ export type AgentSessionSetupState = export interface AgentSessionSetup { /** The engine-derived identity, when an element opened this request. */ readonly sessionIdentity?: string; + /** + * Whether this setup's final request explicitly specifies a model. + * + * Describes this request only; it neither authorizes provider work nor says + * configuration has been applied. The requested value remains private until + * the existing configuration callback receives it. + */ + readonly hasExplicitModel: boolean; /** * Authorize provider work; a retained conversation requires its configuration. * @@ -219,6 +227,7 @@ export function createSessionSetupOwner(): SessionSetupOwner { ...(requested.sessionIdentity === undefined ? {} : { sessionIdentity: requested.sessionIdentity }), + hasExplicitModel: requested.configuration?.model !== undefined, *activate( provider: string, agentCommand: string, diff --git a/packages/core/tests/agent-session-setup.test.ts b/packages/core/tests/agent-session-setup.test.ts index 2ea2c6cc0..6ebfe8f14 100644 --- a/packages/core/tests/agent-session-setup.test.ts +++ b/packages/core/tests/agent-session-setup.test.ts @@ -262,6 +262,30 @@ describe("Tier SP — session setup ownership", () => { expect(refusalOf(() => owner.setup(rebuilt))).toContain("not a live session request"); }); + it("SP12: a setup says whether its final request names a model, and nothing more", function* () { + const owner = createSessionSetupOwner(); + const presence = (configuration: SessionConfiguration | undefined, routed = false) => { + const issuance = openSessionRequest(`expansion:${String(configuration?.model)}`, "review"); + issuance.configure(configuration); + // A handler's whole-request replacement is the request the provider + // receives, so the fact follows that final request. + const request = routed ? issuance.request.with({ name: "elsewhere" }) : issuance.request; + const setup = owner.setup(request); + return { hasExplicitModel: setup.hasExplicitModel, keys: Object.keys(setup).sort() }; + }; + expect(presence({ model: "gpt-5.4" }).hasExplicitModel).toBe(true); + expect(presence({ model: "gpt-5.4", effort: "high" }, true).hasExplicitModel).toBe(true); + expect(presence({ effort: "high" }).hasExplicitModel).toBe(false); + expect(presence(undefined).hasExplicitModel).toBe(false); + // The value itself stays sealed: a setup carries the fact, not the model. + expect(presence({ model: "gpt-5.4" }).keys).toEqual([ + "activate", + "finish", + "hasExplicitModel", + "sessionIdentity", + ]); + }); + it("SP11: what a request asks is settled before it routes, and once", function* () { // The element says what it asks — or that it asks nothing — before any // handler holds the request. What a handler receives is therefore already diff --git a/packages/core/tests/root-provider.test.ts b/packages/core/tests/root-provider.test.ts index 1e2b0c452..3417b2d9a 100644 --- a/packages/core/tests/root-provider.test.ts +++ b/packages/core/tests/root-provider.test.ts @@ -246,6 +246,7 @@ describe("Tier RP — what the delivery adapter carries to a provider", () => { */ class DeliveredSetup { readonly #asked: Asked[]; + readonly hasExplicitModel = false; constructor(asked: Asked[]) { this.#asked = asked; @@ -329,6 +330,64 @@ describe("Tier RP — what the delivery adapter carries to a provider", () => { ]); }); + it("SC5: a delivered setup must say whether its request names a model", function* () { + for (const presence of [undefined, "true", 1]) { + const issuance = openSessionRequest("expansion:1", "review"); + let refusal: Error | undefined; + let forwarded: boolean | undefined; + yield* scoped(function* () { + yield* registerAgentProvider( + "probe", + // deno-lint-ignore require-yield + function* (_options, launchCoordinator): Operation { + try { + forwarded = launchCoordinator.sessionSetup(issuance.request).hasExplicitModel; + } catch (error) { + refusal = error instanceof Error ? error : new Error(String(error)); + } + }, + ); + yield* installAgentProvider( + "probe", + OPTIONS, + coordinatorAnswering(() => { + const handle = new DeliveredSetup([]); + // An untyped embedder's answer, reached without a cast. + Reflect.defineProperty(handle, "hasExplicitModel", { value: presence }); + return handle; + }), + ); + }); + // No fallback guesses a missing or malformed fact. + expect(forwarded).toBe(undefined); + expect(refusal).toBeInstanceOf(AgentProviderInstallError); + expect(refusal?.message).toContain("names a model"); + } + + // A real boolean crosses unchanged. + const issuance = openSessionRequest("expansion:1", "review"); + let forwarded: boolean | undefined; + yield* scoped(function* () { + yield* registerAgentProvider( + "probe", + // deno-lint-ignore require-yield + function* (_options, launchCoordinator): Operation { + forwarded = launchCoordinator.sessionSetup(issuance.request).hasExplicitModel; + }, + ); + yield* installAgentProvider( + "probe", + OPTIONS, + coordinatorAnswering(() => { + const handle = new DeliveredSetup([]); + Reflect.defineProperty(handle, "hasExplicitModel", { value: true }); + return handle; + }), + ); + }); + expect(forwarded).toBe(true); + }); + it("SC5: a delivered setup that authorizes nothing is refused", function* () { const issuance = openSessionRequest("expansion:1", "review"); let refusal: Error | undefined; @@ -350,6 +409,7 @@ describe("Tier RP — what the delivery adapter carries to a provider", () => { OPTIONS, coordinatorAnswering(() => { const handle: AgentSessionSetup = { + hasExplicitModel: false, // deno-lint-ignore require-yield *activate(): Operation> { return Ok(undefined); diff --git a/scripts/tests/acpx-vendor.test.ts b/scripts/tests/acpx-vendor.test.ts index 7bb786c3a..c626d19c6 100644 --- a/scripts/tests/acpx-vendor.test.ts +++ b/scripts/tests/acpx-vendor.test.ts @@ -40,6 +40,8 @@ const SEAMS: readonly string[] = [ "Strict explicit resume:", "XMD prompt diagnostic:", "XMD prompt usage:", + "hasExplicitModel", + "XMD exact-model replay:", ]; /** How far an introduced line may sit from one that names the seam. */ diff --git a/specs/acp-client-spec.md b/specs/acp-client-spec.md index bef811a45..74e99028d 100644 --- a/specs/acp-client-spec.md +++ b/specs/acp-client-spec.md @@ -451,6 +451,44 @@ write, prompt, detach or native process. Reconfiguring an existing session preserves its exact provider identity and its history. A provider that cannot reconfigure that conversation in place refuses. +### A configured conversation that reconnects + +A provider that releases its handle after each owned operation reconnects for +the next one, and a reconnect initializes the resumed conversation before the +steps above can run. Each attaching call therefore states, for itself only, +whether the use it belongs to names a model — `hasExplicitModel` on the ensure, +turn and configuration-write inputs of the in-process runtime. Eager existing +resolution takes it from its setup; a prompt or launch from its final authentic +configuration; a retained launch's reacquisition from its retained +configuration. A configuration attempt's restoring writes carry the same fact +as its ordinary ones. Discovery and unconfigured or effort-only uses omit it, +and omission is false. + +The fact reaches only the client constructions that call itself performs. It is +never saved in creation options, records, routes, journals, encoded handles or +any provider state, so a later operation supplies its own; a retained live +client is not reinitialized for a later use. A non-boolean value refuses, and a +shared runtime refuses `true` before any effect. A Claude `session/load` or +`session/resume` for a call that names a model carries +`_meta["executablemd.has-explicit-model/v1"]: true`; `session/new` and other +adapters carry nothing. + +On such a resume the prepared Claude adapter reasserts no environment or +settings model. It reads the model the resumed backend reports and advertises +that exact string as the current model selection — no picker alias, default +row, context-hint reconstruction or requested value substitutes for it. An +unreadable or empty report, or one no offered model names exactly, refuses the +load or resume as an invalid request before any prompt; this includes a +concrete model offered only through an alias, and a context hint the offered +ids do not spell. Fresh, unconfigured and effort-only sessions keep the +adapter's existing initialization. + +ACPX's reconnect replay of a saved model selection writes nothing when the +resumed session already reports exactly that saved id. A different reported id +is written back through the existing replay, and the steps above verify it. +Repeated operations under an unchanged selection therefore add no model write, +while a genuine change is written once and verified before the next prompt. + The configured-session carrier is issued by Core after `session()` resolves an exact value registered by the installed provider. A Core-owned resolve-and-issue operation supplies the programmatic path; middleware can @@ -540,6 +578,7 @@ interface ProviderSessionConfiguration { interface AgentSessionSetup { readonly sessionIdentity?: string; + readonly hasExplicitModel: boolean; activate( provider: string, agentCommand: string, @@ -594,7 +633,15 @@ conversation's scope. `sessionSetup()` accepts one exact live opaque session request. Its handle exposes the optional durable component identity, authorizes provider work once, -and settles that request once. For a new Session, `finish()` issues the +and settles that request once. `hasExplicitModel` says whether that final +request's configuration names a model: true for `model` alone or with +`effort`, false for omission and `effort` alone. It describes the request only — +it authorizes nothing, says nothing has been applied, and carries no value; the +requested settings stay private until `finish()` hands them to `configure`. It +exists so an eager reattachment can initialize correctly before `finish()` +(§Session configuration). The structural adapter forwards a real boolean and +refuses a setup that supplies anything else; public request middleware receives +no such member. For a new Session, `finish()` issues the configured use without reading or writing provider configuration. For an existing Session, it invokes the provider's stable `configure` operation, requires the returned canonical value to equal the requested members exactly, @@ -1668,7 +1715,8 @@ its caller supplied, from Prompt `timeout` or the enclosing AgentProvider durable identity already exist, and `session()` keeps its eager behavior for one: it takes ownership where the agent needs it, reconciles the retained route, ensures through the bound runtime, validates the exact identity and - host mapping, releases the handle, and starts no turn. + host mapping, releases the handle, and starts no turn. That ensure carries + its setup's `hasExplicitModel` (§A configured conversation that reconnects). Directory resolution classifies a record still carrying ACPX's own pending marker as new, and a record without one — including every record written diff --git a/specs/native-agent-session-launch-spec.md b/specs/native-agent-session-launch-spec.md index c48ac446a..a41e74518 100644 --- a/specs/native-agent-session-launch-spec.md +++ b/specs/native-agent-session-launch-spec.md @@ -1515,7 +1515,10 @@ degrading: first live phase — the detach for a launch that never handed over, the spawn for one that did — it reconciles its route and build, reattaches by the exact retained identity, puts the retained configuration back into force, and - relinquishes ACP again without authoring a second detach. + relinquishes ACP again without authoring a second detach. That reattachment, + and a configured launch's owed materialization turn, state whether their + configuration names a model exactly as any other attaching call does + ([ACP client §A configured conversation that reconnects](acp-client-spec.md#a-configured-conversation-that-reconnects)). - **Released launch model members never become configuration.** A non-empty `requestedModel` or top-level observational `model` string is validated and discarded while reading an older preparation; a malformed value refuses the From c301818c6a74cf03f8d61ceae000b859522884b0 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 9 Nov 2026 13:57:38 -0500 Subject: [PATCH 2/2] refactor(acp): carry the requested model instead of a presence flag --- packages/acp/src/provider.ts | 31 +++--- packages/acp/tests/claude-reconnect.test.ts | 99 ++++++++++--------- packages/acp/tests/helpers.ts | 16 +-- packages/acp/tests/loaded-copy.test.ts | 11 ++- packages/acp/tests/native-launch.test.ts | 26 +++-- packages/acp/tests/provider.test.ts | 38 +++---- packages/acp/vendor/acpx/MANIFEST.json | 8 +- packages/acp/vendor/acpx/PROVENANCE.md | 27 ++--- .../generated/queue-owner-runtime-B0mPqOu4.js | 8 +- .../acp/vendor/acpx/generated/runtime.d.ts | 26 ++--- packages/acp/vendor/acpx/generated/runtime.js | 77 +++++++++------ packages/cli/src/headless/host.ts | 2 +- packages/core/src/agent/provider-api.ts | 13 ++- packages/core/src/agent/session-setup.ts | 10 +- .../core/tests/agent-session-setup.test.ts | 20 ++-- packages/core/tests/root-provider.test.ts | 30 +++--- scripts/tests/acpx-vendor.test.ts | 2 +- specs/acp-client-spec.md | 59 +++++------ 18 files changed, 275 insertions(+), 228 deletions(-) diff --git a/packages/acp/src/provider.ts b/packages/acp/src/provider.ts index 7621f697f..740cbf233 100644 --- a/packages/acp/src/provider.ts +++ b/packages/acp/src/provider.ts @@ -519,13 +519,12 @@ type Prepared = /** * The runtime member a call carries when its use names a model. * - * Present only when true: omission is the runtime's false, and nothing about - * the requested value travels with it. + * Omitted for an unconfigured or effort-only use. */ -function explicitModel(configuration: SessionConfiguration | undefined): { - hasExplicitModel?: true; +function modelRequest(configuration: SessionConfiguration | undefined): { + requestedModel?: string; } { - return configuration?.model === undefined ? {} : { hasExplicitModel: true }; + return configuration?.model === undefined ? {} : { requestedModel: configuration.model }; } function asName(routed: string | Session | undefined): string | undefined { @@ -1816,10 +1815,10 @@ function* useAcpxProviderState( */ deferEstablished?: boolean; /** - * Whether the use this ensure belongs to names a model, for the reconnect + * The model requested by the use this ensure belongs to, for the reconnect * this ensure itself may perform. Never kept on the entry it returns. */ - hasExplicitModel?: boolean; + requestedModel?: string; } function* ensureFromPrepared( @@ -1857,7 +1856,7 @@ function* useAcpxProviderState( ...(intent.materialization === true ? { materialization: "first-turn-acceptance" as const } : {}), - ...(intent.hasExplicitModel === true ? { hasExplicitModel: true } : {}), + ...(intent.requestedModel === undefined ? {} : { requestedModel: intent.requestedModel }), }, (handle, entry) => { // The exact value this provider already issued, when it issued one. @@ -2683,9 +2682,9 @@ function* useAcpxProviderState( handle: entry.handle, key: optionId, value, - // The same attempt's presence for its ordinary and restoring + // The same attempt's requested model for ordinary and restoring // writes alike: either may be the one that reconnects. - ...explicitModel(configuration), + ...modelRequest(configuration), }) .then(() => undefined); yield* until(pending); @@ -2918,7 +2917,7 @@ function* useAcpxProviderState( ...(construction === undefined ? {} : { build: construction.build }), ...(resumeSessionId === undefined ? {} : { attachment: { resumeSessionId } }), state, - ...explicitModel(desired), + ...modelRequest(desired), materialization: constructing && adapterFor(agentName)?.acceptance === "first-turn-acceptance", deferEstablished: @@ -2974,7 +2973,7 @@ function* useAcpxProviderState( mode: "prompt", requestId, timeoutMs, - ...explicitModel(desired), + ...modelRequest(desired), ...(callbacks === undefined ? {} : { @@ -3667,7 +3666,7 @@ function* useAcpxProviderState( ...(bound === undefined ? {} : { build: bound.build }), attachment: { resumeSessionId: prepared.nativeSessionId }, state: "existing", - ...explicitModel(requested), + ...modelRequest(requested), }); } catch (error) { if (error instanceof AttachmentRefused) { @@ -3837,7 +3836,7 @@ function* useAcpxProviderState( text: plan.prompt, mode: "prompt", requestId: plan.requestId, - ...explicitModel(retainedConfiguration(prepared)), + ...modelRequest(retainedConfiguration(prepared)), }); activeTurns.add(turn); @@ -4673,7 +4672,9 @@ function* useAcpxProviderState( ...(construction === undefined ? {} : { build: construction.build }), ...(resumeSessionId === undefined ? {} : { attachment: { resumeSessionId } }), state, - ...(setup?.hasExplicitModel === true ? { hasExplicitModel: true } : {}), + ...(setup?.requestedModel === undefined + ? {} + : { requestedModel: setup.requestedModel }), }); // An existing session is reattached here: its route and its // identity already exist, so this is a conversation that can be diff --git a/packages/acp/tests/claude-reconnect.test.ts b/packages/acp/tests/claude-reconnect.test.ts index 868501465..3a77ec612 100644 --- a/packages/acp/tests/claude-reconnect.test.ts +++ b/packages/acp/tests/claude-reconnect.test.ts @@ -136,13 +136,13 @@ function observed(entries: Logged[]) { const KEY = "cr"; -function input(world: World, hasExplicitModel?: boolean): AcpRuntimeEnsureInput { +function input(world: World, requestedModel?: string): AcpRuntimeEnsureInput { return { sessionKey: KEY, agent: "claude", mode: "persistent", cwd: world.root, - ...(hasExplicitModel === undefined ? {} : { hasExplicitModel }), + ...(requestedModel === undefined ? {} : { requestedModel }), }; } @@ -151,10 +151,10 @@ function* ownedTurn( world: World, runtime: Runtime, text: string, - hasExplicitModel?: boolean, + requestedModel?: string, ): Operation<{ status: string }> { return yield* scoped(function* () { - const handle = yield* until(runtime.ensureSession(input(world, hasExplicitModel))); + const handle = yield* until(runtime.ensureSession(input(world, requestedModel))); yield* ensure(() => until(runtime.close({ handle, reason: "released after the owned prompt" })), ); @@ -163,7 +163,7 @@ function* ownedTurn( text, mode: "prompt", requestId: text, - ...(hasExplicitModel === undefined ? {} : { hasExplicitModel }), + ...(requestedModel === undefined ? {} : { requestedModel }), }); const result = yield* until(turn.result); return { status: result.status }; @@ -176,17 +176,17 @@ function* ownedWrite( runtime: Runtime, key: string, value: string, - hasExplicitModel?: boolean, + requestedModel?: string, ): Operation { return yield* scoped(function* () { - const handle = yield* until(runtime.ensureSession(input(world, hasExplicitModel))); + const handle = yield* until(runtime.ensureSession(input(world, requestedModel))); yield* ensure(() => until(runtime.close({ handle, reason: "released after the owned write" }))); yield* until( runtime.setConfigOption({ handle, key, value, - ...(hasExplicitModel === undefined ? {} : { hasExplicitModel }), + ...(requestedModel === undefined ? {} : { requestedModel }), }), ); return handle; @@ -200,11 +200,11 @@ function* established( model: string, effort?: string, ): Operation { - yield* ownedWrite(world, runtime, "model", model, true); + yield* ownedWrite(world, runtime, "model", model, model); if (effort !== undefined) { - yield* ownedWrite(world, runtime, "effort", effort, true); + yield* ownedWrite(world, runtime, "effort", effort, model); } - yield* ownedTurn(world, runtime, "first", true); + yield* ownedTurn(world, runtime, "first", model); } describe( @@ -225,7 +225,7 @@ describe( expect(observed(yield* logged(world)).modelWrites).toEqual([EXPLICIT]); for (const text of ["second", "third", "fourth"]) { - expect(yield* ownedTurn(world, runtime, text, true)).toEqual({ status: "completed" }); + expect(yield* ownedTurn(world, runtime, text, EXPLICIT)).toEqual({ status: "completed" }); } const entries = (yield* logged(world)).slice(before); @@ -258,22 +258,22 @@ describe( const runtime = runtimeFor(world); yield* ensure(() => until(runtime.shutdown())); yield* established(world, runtime, EXPLICIT); - yield* ownedWrite(world, runtime, "effort", "high", true); + yield* ownedWrite(world, runtime, "effort", "high", EXPLICIT); const before = (yield* logged(world)).length; // A cold control reconnect for another setting writes no model. - yield* ownedWrite(world, runtime, "effort", "low", true); + yield* ownedWrite(world, runtime, "effort", "low", EXPLICIT); expect(observed((yield* logged(world)).slice(before)).modelWrites).toEqual([]); // A genuine change is written once, exactly, and verified in status // before the next prompt — which then runs under it with no write. - const changed = yield* ownedWrite(world, runtime, "model", ANOTHER, true); + const changed = yield* ownedWrite(world, runtime, "model", ANOTHER, ANOTHER); const status = yield* until(runtime.getStatus({ handle: changed })); expect(status.models?.currentModelId).toBe(ANOTHER); // The new model's effort choices are refreshed, so the configured // effort is written for it, as session configuration does. - yield* ownedWrite(world, runtime, "effort", "low", true); - yield* ownedTurn(world, runtime, "after the change", true); + yield* ownedWrite(world, runtime, "effort", "low", ANOTHER); + yield* ownedTurn(world, runtime, "after the change", ANOTHER); const after = observed((yield* logged(world)).slice(before)); expect(after.modelWrites).toEqual([ANOTHER]); expect(after.prompts.map((prompt) => [prompt.model, prompt.effort])).toEqual([ @@ -315,34 +315,37 @@ describe( }); }); - it("CR4: the presence fact reaches only the reconnect and is never retained", function* () { + it("CR4: the requested model reaches only this call and is never retained", function* () { const world = yield* useWorld("fable"); yield* scoped(function* () { const runtime = runtimeFor(world); yield* ensure(() => until(runtime.shutdown())); yield* established(world, runtime, EXPLICIT); - yield* ownedTurn(world, runtime, "again", true); + yield* ownedTurn(world, runtime, "again", EXPLICIT); const durable = JSON.stringify([...world.records.values()]); expect(durable).not.toContain("has-explicit-model"); - expect(durable).not.toContain("hasExplicitModel"); + expect(durable).not.toContain("requestedModel"); // It never reaches Claude Code's own options or arguments either. const launches = (yield* logged(world)).filter((entry) => entry.argv !== undefined); expect(JSON.stringify(launches)).not.toContain("has-explicit-model"); - const handle = yield* until(runtime.ensureSession(input(world, true))); + const handle = yield* until(runtime.ensureSession(input(world, EXPLICIT))); expect(JSON.stringify(handle)).not.toContain("xplicit"); yield* until(runtime.close({ handle, reason: "inspected" })); - // A malformed fact refuses before any effect. + // Malformed values refuse before any native work. const starts = (yield* logged(world)).length; - const malformed = { ...input(world), hasExplicitModel: "yes" as unknown as boolean }; - const refusal = yield* until( - runtime.ensureSession(malformed).then( - () => "", - (error: Error) => error.message, - ), - ); - expect(refusal).toContain("hasExplicitModel must be a boolean"); - expect((yield* logged(world)).length).toBe(starts); + for (const value of [true, false, 1, "", " "]) { + const malformed = input(world); + Reflect.set(malformed, "requestedModel", value); + const refusal = yield* until( + runtime.ensureSession(malformed).then( + () => "", + (error: Error) => error.message, + ), + ); + expect(refusal).toContain("requestedModel must be a non-empty string"); + expect((yield* logged(world)).length).toBe(starts); + } }); }); @@ -359,7 +362,7 @@ describe( yield* writeTextFile(conversation, JSON.stringify({ ...persisted, model: ANOTHER })); const before = (yield* logged(world)).length; - yield* ownedTurn(world, runtime, "after native", true); + yield* ownedTurn(world, runtime, "after native", EXPLICIT); const after = observed((yield* logged(world)).slice(before)); // The reconnect saw the real change, so the selection is written back // once — exactly — and the prompt runs under it in the same conversation. @@ -387,11 +390,11 @@ describe( yield* scoped(function* () { const runtime = runtimeFor(world, { FAKE_CLAUDE_MODELS: aliasOnly }); yield* ensure(() => until(runtime.shutdown())); - yield* ownedWrite(world, runtime, "model", "fable", true); + yield* ownedWrite(world, runtime, "model", "fable", "fable"); // The backend reports the concrete model behind the alias. Nothing offered // names it exactly, so a use naming its own model refuses rather than // inventing that row or reading it as the alias. - const refused = yield* refusalOfTurn(world, runtime, "flagged", true); + const refused = yield* refusalOfTurn(world, runtime, "flagged", "fable"); expect(refused).toContain("claude-fable-5-1"); expect(observed(yield* logged(world)).prompts).toEqual([]); // Without the fact, the existing behavior stands. @@ -406,7 +409,7 @@ describe( const failing = runtimeFor(unreadable, { FAKE_CLAUDE_CONTEXT_USAGE: "fail" }); yield* ensure(() => until(failing.shutdown())); const prompts = observed(yield* logged(unreadable)).prompts.length; - const refused = yield* refusalOfTurn(unreadable, failing, "flagged", true); + const refused = yield* refusalOfTurn(unreadable, failing, "flagged", EXPLICIT); expect(refused).toContain("could not report the model it runs"); expect(observed(yield* logged(unreadable)).prompts.length).toBe(prompts); }); @@ -454,24 +457,24 @@ describe( agentProcessEnv: { WIRE_LOG: wire }, }); yield* ensure(() => until(runtime.shutdown())); - const ensureInput = (hasExplicitModel?: boolean): AcpRuntimeEnsureInput => ({ + const ensureInput = (requestedModel?: string): AcpRuntimeEnsureInput => ({ sessionKey: "wire", agent: "agent", mode: "persistent", cwd: root, - ...(hasExplicitModel === undefined ? {} : { hasExplicitModel }), + ...(requestedModel === undefined ? {} : { requestedModel }), }); // Created under a flagged call: session/new never carries it. - const created = yield* until(runtime.ensureSession(ensureInput(true))); + const created = yield* until(runtime.ensureSession(ensureInput(EXPLICIT))); yield* until(runtime.close({ handle: created, reason: "release" })); - for (const hasExplicitModel of [true, false, undefined, true]) { - const handle = yield* until(runtime.ensureSession(ensureInput(hasExplicitModel))); + for (const requestedModel of [EXPLICIT, undefined, undefined, ANOTHER]) { + const handle = yield* until(runtime.ensureSession(ensureInput(requestedModel))); const turn = runtime.startTurn({ handle, text: "hello", mode: "prompt", - requestId: `turn-${String(hasExplicitModel)}`, - ...(hasExplicitModel === undefined ? {} : { hasExplicitModel }), + requestId: `turn-${String(requestedModel)}`, + ...(requestedModel === undefined ? {} : { requestedModel }), }); yield* until(turn.result); yield* until(runtime.close({ handle, reason: "release" })); @@ -523,7 +526,7 @@ describe( sessionKey: "shared", agent: "claude", mode: "persistent", - hasExplicitModel: true, + requestedModel: EXPLICIT, }), ), ).toContain("not supported by shared sessions"); @@ -534,12 +537,12 @@ describe( text: "x", mode: "prompt", requestId: "x", - hasExplicitModel: true, + requestedModel: EXPLICIT, }), ), ).toContain("not supported by shared sessions"); const write = yield* until( - shared.setConfigOption({ handle, key: "model", value: "m", hasExplicitModel: true }).then( + shared.setConfigOption({ handle, key: "model", value: "m", requestedModel: EXPLICIT }).then( () => "", (error: Error) => error.message, ), @@ -679,10 +682,10 @@ function* refusalOfTurn( world: World, runtime: Runtime, text: string, - hasExplicitModel: boolean, + requestedModel: string, ): Operation { return yield* scoped(function* () { - const handle = yield* until(runtime.ensureSession(input(world, hasExplicitModel))); + const handle = yield* until(runtime.ensureSession(input(world, requestedModel))); yield* ensure(() => until(runtime.close({ handle, reason: "refused" }))); try { const turn = runtime.startTurn({ @@ -690,7 +693,7 @@ function* refusalOfTurn( text, mode: "prompt", requestId: text, - hasExplicitModel, + requestedModel, }); const result = yield* until(turn.result); return result.status === "completed" ? "" : JSON.stringify(result); diff --git a/packages/acp/tests/helpers.ts b/packages/acp/tests/helpers.ts index a7f09c47c..1c812d43f 100644 --- a/packages/acp/tests/helpers.ts +++ b/packages/acp/tests/helpers.ts @@ -127,10 +127,10 @@ export interface FakeRuntimeHarness { /** * Whether each ensure, turn and write said its use names a model, in order. * - * ` `, with `-` for an omitted member, so a case can - * state that the presence travelled with exactly the calls it belongs to. + * ` `, with `-` for an omitted member, so a case can + * state that the requested model travelled with exactly the calls it belongs to. */ - presence: string[]; + modelRequests: string[]; closeCalls: AcpRuntimeHandle[]; /** * Every close, whole. @@ -375,7 +375,7 @@ export function createFakeRuntime(): FakeRuntimeHarness { doctorCalls: 0, ensureCalls: [], turns: [], - presence: [], + modelRequests: [], handleIds: [], startedTurns(count) { return { @@ -415,7 +415,7 @@ export function createFakeRuntime(): FakeRuntimeHarness { } harness.activity?.("ensure"); harness.ensureCalls.push(input); - harness.presence.push(`ensure ${input.hasExplicitModel ?? "-"}`); + harness.modelRequests.push(`ensure ${input.requestedModel ?? "-"}`); const handleId = `${input.sessionKey}#${harness.handleIds.length}`; harness.handleIds.push(handleId); // Deferred materialization: the record occupies the key and asserts @@ -470,7 +470,7 @@ export function createFakeRuntime(): FakeRuntimeHarness { return gate ? run(() => gate).then(answer) : Promise.resolve(answer()); }, startTurn(input) { - harness.presence.push(`turn ${input.hasExplicitModel ?? "-"}`); + harness.modelRequests.push(`turn ${input.requestedModel ?? "-"}`); harness.activity?.("turn"); const script = scripted.shift() ?? {}; const recordId = input.handle.acpxRecordId ?? input.handle.sessionKey; @@ -633,9 +633,9 @@ export function createFakeRuntime(): FakeRuntimeHarness { handle: AcpRuntimeHandle; key: string; value: string; - hasExplicitModel?: boolean; + requestedModel?: string; }) { - harness.presence.push(`set ${input.key} ${input.hasExplicitModel ?? "-"}`); + harness.modelRequests.push(`set ${input.key} ${input.requestedModel ?? "-"}`); harness.activity?.(`set ${input.key}=${input.value}`); harness.configCalls.push(`set ${input.key}=${input.value}`); const failure = harness.writeFailures?.[input.key]; diff --git a/packages/acp/tests/loaded-copy.test.ts b/packages/acp/tests/loaded-copy.test.ts index 6f9416246..e0ce0dcc4 100644 --- a/packages/acp/tests/loaded-copy.test.ts +++ b/packages/acp/tests/loaded-copy.test.ts @@ -161,15 +161,18 @@ function sessionSetupOf(value: unknown): AgentSessionSetup { throw new Error("the other copy answered a session setup with nothing that could settle it"); } const identity = Reflect.get(value, "sessionIdentity"); - const hasExplicitModel = Reflect.get(value, "hasExplicitModel"); - if (typeof hasExplicitModel !== "boolean") { + const requestedModel = Reflect.get(value, "requestedModel"); + if ( + !Reflect.has(value, "requestedModel") || + (requestedModel !== undefined && typeof requestedModel !== "string") + ) { throw new Error( "the other copy answered a session setup that does not say whether it names a model", ); } return { ...(typeof identity === "string" ? { sessionIdentity: identity } : {}), - hasExplicitModel, + requestedModel, *activate(provider, agentCommand, configuration) { // Across the same seam as `finish`, for the same reason: the authority // that decides whether this provider may work lives in the copy that @@ -336,7 +339,7 @@ function foreignCoordinator(owner: ForeignOwner, log: SessionSetupLog): AgentLau const setup = owner.setup(request); const identity = setup.sessionIdentity; const observed: AgentSessionSetup = { - hasExplicitModel: setup.hasExplicitModel, + requestedModel: setup.requestedModel, activate: (provider, agentCommand, configuration) => setup.activate(provider, agentCommand, configuration), finish: (session, state) => setup.finish(session, state), diff --git a/packages/acp/tests/native-launch.test.ts b/packages/acp/tests/native-launch.test.ts index 0a5409b56..90f07a39a 100644 --- a/packages/acp/tests/native-launch.test.ts +++ b/packages/acp/tests/native-launch.test.ts @@ -295,7 +295,7 @@ function traceCoordinator(trace: Trace): AgentLaunchCoordinator { ...(requested.sessionIdentity === undefined ? {} : { sessionIdentity: requested.sessionIdentity }), - hasExplicitModel: requested.configuration?.model !== undefined, + requestedModel: requested.configuration?.model, // This trace's setups carry no retained conversation, so there is // nothing to be held to and activation only records what the provider // declared it was about to build under. It is still required: a @@ -5088,7 +5088,11 @@ describe("Tier NC — configured native launch", () => { expect(trace.launches[0]?.command).toContain(prepared.nativeSessionId); // A fresh creation resumes nothing; both writes belong to this use, which // names a model. - expect(harness.presence).toEqual(["ensure -", "set model true", "set effort true"]); + expect(harness.modelRequests).toEqual([ + "ensure -", + "set model gpt-5.4-mini", + "set effort gpt-5.4-mini", + ]); }); }); @@ -5212,11 +5216,11 @@ describe("Tier NC — configured native launch", () => { "exited", ]); // The owed turn is spent under the launch's own configured use. - expect(harness.presence).toEqual([ + expect(harness.modelRequests).toEqual([ "ensure -", - "set model true", - "set effort true", - "turn true", + "set model gpt-5.4-mini", + "set effort gpt-5.4-mini", + "turn gpt-5.4-mini", ]); }); }); @@ -5330,8 +5334,12 @@ describe("Tier NR — configured incomplete replay", () => { expect(harness.ensureCalls[0]?.resumeSessionId).toBe(`agent-session:${SESSION_KEY}`); expect(harness.turns.length).toBe(0); expect(trace.launches[0]?.command).toContain(`agent-session:${SESSION_KEY}`); - // The reacquisition resumes under the retained configuration's presence. - expect(harness.presence).toEqual(["ensure true", "set model true", "set effort true"]); + // The reacquisition resumes under the retained configuration's requested model. + expect(harness.modelRequests).toEqual([ + "ensure gpt-5.4-mini", + "set model gpt-5.4-mini", + "set effort gpt-5.4-mini", + ]); }); }); @@ -5566,7 +5574,7 @@ describe("Tier NR — configured incomplete replay", () => { expect(trace.order).toEqual(["prepared", "detached", "spawn", "exited"]); expect(harness.ensureCalls).toEqual([]); expect(harness.configCalls).toEqual([]); - expect(harness.presence).toEqual([]); + expect(harness.modelRequests).toEqual([]); }); }); diff --git a/packages/acp/tests/provider.test.ts b/packages/acp/tests/provider.test.ts index bdb869c99..8288e0005 100644 --- a/packages/acp/tests/provider.test.ts +++ b/packages/acp/tests/provider.test.ts @@ -897,7 +897,7 @@ function stubCoordinator(): CoordinatorLog { const setup = owner.setup(request); return { ...(setup.sessionIdentity === undefined ? {} : { sessionIdentity: setup.sessionIdentity }), - hasExplicitModel: setup.hasExplicitModel, + requestedModel: setup.requestedModel, // Through the genuine owner, like `finish`: this suite's provider work // is authorized by the same operation a conversation's requirement // would reach, so a provider that settled a setup it never activated @@ -2930,7 +2930,7 @@ describe("Tier SE — reconfiguring an owned session", () => { }); }); - it("SE3: each reconnecting call carries only its own use's model presence", function* () { + it("SE3: each reconnecting call carries only its own use's requested model", function* () { const harness = configured(createFakeRuntime()); const ownership = makeCoordinator(); yield* scoped(function* () { @@ -2938,40 +2938,40 @@ describe("Tier SE — reconfiguring an owned session", () => { // A first configured prompt: the ensure, both writes and the turn all // belong to a use that names a model. yield* configuredPrompt(coordinator, { model: "gpt-5.4-mini", effort: "high" }); - expect(harness.presence).toEqual([ - "ensure true", - "set model true", - "set effort true", - "turn true", + expect(harness.modelRequests).toEqual([ + "ensure gpt-5.4-mini", + "set model gpt-5.4-mini", + "set effort gpt-5.4-mini", + "turn gpt-5.4-mini", ]); // A later unconfigured resolution and prompt inherit nothing from the // earlier use: the eager reattachment and the prompt's own both omit it. - harness.presence = []; + harness.modelRequests = []; const plain = yield* Agent.operations.session("review"); yield* collectPrompt("plain", { session: plain }); - expect(harness.presence).toEqual(["ensure -", "ensure -", "turn -"]); + expect(harness.modelRequests).toEqual(["ensure -", "ensure -", "turn -"]); // Eager existing resolution forwards the setup's presence before its // ensure, then configures through finish; effort-only names no model. - harness.presence = []; + harness.modelRequests = []; yield* configuredSession(coordinator, "review", { effort: "low" }); - expect(harness.presence).toEqual(["ensure -", "set effort -"]); - harness.presence = []; + expect(harness.modelRequests).toEqual(["ensure -", "set effort -"]); + harness.modelRequests = []; yield* configuredSession(coordinator, "review", { model: "gpt-5.4" }); - expect(harness.presence).toEqual(["ensure true", "set model true"]); + expect(harness.modelRequests).toEqual(["ensure gpt-5.4", "set model gpt-5.4"]); // A restoring write belongs to the attempt that made it. - harness.presence = []; + harness.modelRequests = []; harness.writeFailures = { effort: new Error("the agent refused that effort level") }; yield* refusalOf(() => configuredSession(coordinator, "review", { model: "gpt-5.4-mini", effort: "high" }), ); - expect(harness.presence).toEqual([ - "ensure true", - "set model true", - "set effort true", - "set model true", + expect(harness.modelRequests).toEqual([ + "ensure gpt-5.4-mini", + "set model gpt-5.4-mini", + "set effort gpt-5.4-mini", + "set model gpt-5.4-mini", ]); }); }); diff --git a/packages/acp/vendor/acpx/MANIFEST.json b/packages/acp/vendor/acpx/MANIFEST.json index 8a3b0a263..0cbcc9cc5 100644 --- a/packages/acp/vendor/acpx/MANIFEST.json +++ b/packages/acp/vendor/acpx/MANIFEST.json @@ -31,7 +31,7 @@ { "path": "PROVENANCE.md", "kind": "provenance", - "sha256": "e5330c09a3b1320768e898154c49d7f1e89743f2c974a60bf3271e1e7f09b824" + "sha256": "f1b825fc9603337eba9d12d607127892ba0ff33c54a20066f7c72bdaaa300ac2" }, { "path": "generated/agent-registry-Cz9fD9H1.js", @@ -56,17 +56,17 @@ { "path": "generated/queue-owner-runtime-B0mPqOu4.js", "kind": "generated", - "sha256": "bb84664a2dc8b222695f524932617d986b75cd4fba136020d1dfabef45d6a5a4" + "sha256": "e2bd0fd71b49e51697d42e7f4dae7a97aee525dfe17ddbeab8efae2d93951b6c" }, { "path": "generated/runtime.d.ts", "kind": "generated", - "sha256": "27264c3e9ef507558cef778a4757a17807fc970ea94cea4830aec089e91957da" + "sha256": "e1e0669b91a84d25fbec7bdf37546ef4bd30e8e9475caffb005ad01d0246b305" }, { "path": "generated/runtime.js", "kind": "generated", - "sha256": "dbf126f17993045a2a1735b16534c1fec373bb0ab6d9c4e96000b314a7616fa0" + "sha256": "3451eeb74232ef72150d7ae9d27dad47dd538310a62a15fbc5b3e493aab89b33" }, { "path": "generated/session-options-BlnP3WAc.d.ts", diff --git a/packages/acp/vendor/acpx/PROVENANCE.md b/packages/acp/vendor/acpx/PROVENANCE.md index 0d3862c64..702a1ff8d 100644 --- a/packages/acp/vendor/acpx/PROVENANCE.md +++ b/packages/acp/vendor/acpx/PROVENANCE.md @@ -170,18 +170,21 @@ can run. The released runtime has no per-call input for that initialization: return without constructing the client a later prompt or control reconnects through. -The `hasExplicitModel` seam adds one optional boolean to the in-process ensure, -turn and `setConfigOption` inputs. The runtime refuses a non-boolean, and the -shared runtime refuses `true` before any effect. The manager hands the value to -the clients that call constructs — ensure creation, the cold turn client and -the cold control client — and to nothing else: no record, saved option, handle -encoding or retained owner holds it, and a retained live client is reused -unchanged. `AcpClient.continuationMeta()` merges -`"executablemd.has-explicit-model/v1": true` into Claude `session/load` and -`session/resume` metadata for a `true` call; `session/new` and other adapters -are unchanged. `runtime.js` carries validation and forwarding, -`queue-owner-runtime-B0mPqOu4.js` the metadata, and `runtime.d.ts` the -declarations. +The `XMD requested model:` seam carries an optional `requestedModel` string on +in-process ensure, turn and `setConfigOption` inputs. The runtime rejects a +supplied value that is not a non-empty string; the shared runtime rejects any +supplied model before effects. The manager forwards the exact requested value +to the clients constructed by that call: ensure creation, cold turns and cold +controls. Records, creation options, encoded handles and retained owners store +none of it. A retained live client remains unchanged. + +`AcpClient.continuationMeta()` checks presence and derives the existing +`"executablemd.has-explicit-model/v1": true` marker for Claude `session/load` +and `session/resume`. The adapter does not receive the requested string or use +it as a substitute for the resumed backend observation. `session/new`, other +adapters and the readable Claude producer patch are unchanged. `runtime.js` +owns validation and forwarding, `queue-owner-runtime-B0mPqOu4.js` derives the +metadata marker, and `runtime.d.ts` declares the inputs. ## Exact-model replay (#1177) diff --git a/packages/acp/vendor/acpx/generated/queue-owner-runtime-B0mPqOu4.js b/packages/acp/vendor/acpx/generated/queue-owner-runtime-B0mPqOu4.js index cc3ea797e..f65ddb85c 100644 --- a/packages/acp/vendor/acpx/generated/queue-owner-runtime-B0mPqOu4.js +++ b/packages/acp/vendor/acpx/generated/queue-owner-runtime-B0mPqOu4.js @@ -3065,11 +3065,11 @@ var AcpClient = class { const { command, args } = resolveAgentCommandParts(this.options.agentCommand, this.options.agentArgv); return buildClaudeCodeOptionsMeta(this.options.sessionOptions, isClaudeAcpCommand(command, args)); } - /** hasExplicitModel: this client's own call names a model; only Claude load/resume hears it, never session/new. */ + /** XMD requested model: only its presence reaches Claude load/resume metadata, never session/new. */ continuationMeta() { const meta = this.sessionMeta(); const { command, args } = resolveAgentCommandParts(this.options.agentCommand, this.options.agentArgv); - if (this.options.hasExplicitModel !== true || !isClaudeAcpCommand(command, args)) return meta; + if (this.options.requestedModel === void 0 || !isClaudeAcpCommand(command, args)) return meta; return { ...meta, "executablemd.has-explicit-model/v1": true @@ -3127,7 +3127,7 @@ var AcpClient = class { sessionId, cwd: sessionCwd, mcpServers: this.options.mcpServers ?? [], - // hasExplicitModel + // XMD requested model: continuation metadata belongs to this call. _meta: this.continuationMeta() }), options.authority); await this.waitForSessionUpdateDrain(options.replayIdleMs ?? REPLAY_IDLE_MS, options.replayDrainTimeoutMs ?? REPLAY_DRAIN_TIMEOUT_MS); @@ -3147,7 +3147,7 @@ var AcpClient = class { sessionId, cwd: sessionCwd, mcpServers: this.options.mcpServers ?? [], - // hasExplicitModel + // XMD requested model: continuation metadata belongs to this call. _meta: this.continuationMeta() }), authority); this.loadedSessionId = sessionId; diff --git a/packages/acp/vendor/acpx/generated/runtime.d.ts b/packages/acp/vendor/acpx/generated/runtime.d.ts index 9ab226719..44f780864 100644 --- a/packages/acp/vendor/acpx/generated/runtime.d.ts +++ b/packages/acp/vendor/acpx/generated/runtime.d.ts @@ -86,13 +86,13 @@ type AcpRuntimeEnsureInput = { */ sessionOptions?: SessionAgentOptions; /** - * Whether this call belongs to a use that explicitly specifies a model. + * XMD requested model: the exact model requested by this call's use. * - * Applies only to this call's Claude reconnects. Omission is false. It carries - * no requested value and does not apply or verify configuration. Shared - * runtimes refuse true; a non-boolean refuses. + * Applies only to this call's Claude reconnects. Its presence selects the + * continuation behavior; it does not apply or verify configuration. Shared + * runtimes refuse a supplied model; malformed or empty strings refuse. */ - hasExplicitModel?: boolean; + requestedModel?: string; }; type AcpRuntimeTurnAttachment = { /** @@ -121,8 +121,8 @@ type AcpRuntimeTurnInput = AcpControlAuthority & { onPermissionRequest?: AcpPermissionHandler; /** Handles ACP elicitation requests owned by this prompt turn. */ onElicitation?: AcpElicitationHandler; - /** Whether this turn's use names a model; see `AcpRuntimeEnsureInput.hasExplicitModel`. */ - hasExplicitModel?: boolean; + /** XMD requested model: the model requested by this turn's use; see `AcpRuntimeEnsureInput.requestedModel`. */ + requestedModel?: string; }; /** Caller-owned snapshot; local mutations do not change runtime capabilities. */ type AcpRuntimeCapabilities = { @@ -421,8 +421,8 @@ interface AcpRuntime { handle: AcpRuntimeHandle; key: string; value: string; - /** Whether this write's use names a model; see `AcpRuntimeEnsureInput.hasExplicitModel`. */ - hasExplicitModel?: boolean; + /** XMD requested model: the model requested by this write's use; see `AcpRuntimeEnsureInput.requestedModel`. */ + requestedModel?: string; }): Promise; doctor?(): Promise; cancel(input: { @@ -798,8 +798,8 @@ export declare class SharedAcpRuntime { handle: AcpRuntimeHandle; key: string; value: string; - /** Whether this write's use names a model; see `AcpRuntimeEnsureInput.hasExplicitModel`. */ - hasExplicitModel?: boolean; + /** XMD requested model: the model requested by this write's use; see `AcpRuntimeEnsureInput.requestedModel`. */ + requestedModel?: string; }): Promise; watchSession(input: { handle: AcpRuntimeHandle; @@ -884,8 +884,8 @@ export declare class AcpxRuntime implements AcpxRuntimeLike { handle: AcpRuntimeHandle; key: string; value: string; - /** Whether this write's use names a model; see `AcpRuntimeEnsureInput.hasExplicitModel`. */ - hasExplicitModel?: boolean; + /** XMD requested model: the model requested by this write's use; see `AcpRuntimeEnsureInput.requestedModel`. */ + requestedModel?: string; }): Promise; cancel(input: { handle: AcpRuntimeHandle; diff --git a/packages/acp/vendor/acpx/generated/runtime.js b/packages/acp/vendor/acpx/generated/runtime.js index 927349918..0c83128d4 100644 --- a/packages/acp/vendor/acpx/generated/runtime.js +++ b/packages/acp/vendor/acpx/generated/runtime.js @@ -1387,7 +1387,8 @@ var AcpRuntimeManager = class { terminal: this.options.terminal }; } - async withRuntimeControlSession(record, sessionMode, run, replayOptions, authority, hasExplicitModel = false) { + // XMD requested model: this control call supplies its own selection. + async withRuntimeControlSession(record, sessionMode, run, replayOptions, authority, requestedModel) { await this.retrySessionCleanup(record.acpxRecordId); assertControlAuthority(authority); const owner = await this.readRetainedSessionOwner(record, { consume: false }); @@ -1424,7 +1425,8 @@ var AcpRuntimeManager = class { saveRecord: async () => checkpoint.checkpoint(), createClient: (options) => controlClient = this.createClient({ ...options, - hasExplicitModel, + // XMD requested model: only this cold control client receives it. + requestedModel, processLifecycle: this.options.processLifecycle, processLaunchScope: { kind: "runtime-session", @@ -1613,7 +1615,8 @@ var AcpRuntimeManager = class { }, verbose: this.options.verbose, sessionOptions: input.sessionOptions, - hasExplicitModel: input.hasExplicitModel === true + // XMD requested model: the call, not the session record. + requestedModel: input.requestedModel }); const owner = this.createSessionOwner({ client, @@ -1859,8 +1862,8 @@ var AcpRuntimeManager = class { async prepareRuntimeTurnWithOwnership(task) { const { record, retainedOwner, conversation, acpxState, promptMessageId } = await this.prepareRuntimeTurnState(task); try { - // hasExplicitModel: a retained live client is not reinitialized for a later use. - const client = retainedOwner?.client ?? this.createTurnClient(record, task.input.hasExplicitModel === true); + // XMD requested model: a retained live client is not reinitialized for a later use. + const client = retainedOwner?.client ?? this.createTurnClient(record, task.input.requestedModel); const owner = this.resolveRuntimeTurnOwner(task, record, client, retainedOwner); const turn = this.createRunningRuntimeTurn({ record, @@ -1968,7 +1971,8 @@ var AcpRuntimeManager = class { owner.bufferSessionUpdates = false; this.drainPendingSessionUpdates(owner); } - createTurnClient(record, hasExplicitModel = false) { + // XMD requested model: a cold turn uses its own request. + createTurnClient(record, requestedModel) { return this.createClient({ agentCommand: record.agentCommand, agentArgv: record.agentArgv, @@ -1990,7 +1994,8 @@ var AcpRuntimeManager = class { }, verbose: this.options.verbose, sessionOptions: sessionOptionsFromRecord(record), - hasExplicitModel + // XMD requested model: consumed only by this attaching client. + requestedModel }); } buildRuntimeTurnController(task, turn) { @@ -2257,11 +2262,12 @@ var AcpRuntimeManager = class { connectedRecord.acpx = applyModelSelection(connectedRecord.acpx, model, response, resolvedModelId); }, { replacingConfigOption: { key: "model" } }, authority); } - async setConfigOption(handle, key, value, sessionMode = "persistent", authority, hasExplicitModel = false) { + // XMD requested model: ordinary and restoring writes carry the same request. + async setConfigOption(handle, key, value, sessionMode = "persistent", authority, requestedModel) { const recordId = handle.acpxRecordId ?? handle.sessionKey; - return await this.withManagerLock(this.runtimeOperationLocks, recordId, async () => this.setConfigOptionWithOwnership(handle, key, value, sessionMode, authority, hasExplicitModel), authority); + return await this.withManagerLock(this.runtimeOperationLocks, recordId, async () => this.setConfigOptionWithOwnership(handle, key, value, sessionMode, authority, requestedModel), authority); } - async setConfigOptionWithOwnership(handle, key, value, sessionMode, authority, hasExplicitModel) { + async setConfigOptionWithOwnership(handle, key, value, sessionMode, authority, requestedModel) { const record = await this.requireRecord(handle.acpxRecordId ?? handle.sessionKey); assertControlAuthority(authority); const controller = this.activeControllers.get(record.acpxRecordId); @@ -2284,7 +2290,8 @@ var AcpRuntimeManager = class { }, { replacingConfigOption: { key, resolve: (connectedRecord) => resolveSupportedConfigOptionId(connectedRecord, key) - } }, authority, hasExplicitModel); + // XMD requested model: this control may construct the attaching client. + } }, authority, requestedModel); } async cancel(handle) { await this.activeControllers.get(handle.acpxRecordId ?? handle.sessionKey)?.requestCancelActivePrompt(); @@ -2508,15 +2515,15 @@ const SHARED_BACKEND = "acpx-shared"; function invalidOption(message) { throw new AcpRuntimeError("ACP_INVALID_RUNTIME_OPTION", message); } -/** hasExplicitModel: a call's own presence fact; omission is false and nothing else is accepted. */ -function hasExplicitModelOf(input) { - const hasExplicitModel = input?.hasExplicitModel; - if (hasExplicitModel !== void 0 && typeof hasExplicitModel !== "boolean") invalidOption("hasExplicitModel must be a boolean when supplied."); - return hasExplicitModel === true; +/** XMD requested model: this call's exact requested model; omission names none. */ +function requestedModelOf(input) { + const requestedModel = input?.requestedModel; + if (requestedModel !== void 0 && (typeof requestedModel !== "string" || requestedModel.trim().length === 0)) invalidOption("requestedModel must be a non-empty string when supplied."); + return requestedModel; } -/** hasExplicitModel reaches only in-process Claude reconnects; shared sessions refuse it before any effect. */ -function refuseSharedHasExplicitModel(input) { - if (hasExplicitModelOf(input)) invalidOption("hasExplicitModel belongs to the in-process runtime and is not supported by shared sessions."); +/** XMD requested model: reaches only in-process Claude reconnects; shared sessions refuse it before any effect. */ +function refuseSharedRequestedModel(input) { + if (requestedModelOf(input) !== void 0) invalidOption("requestedModel belongs to the in-process runtime and is not supported by shared sessions."); } function sharedHandle(record) { return { @@ -2613,7 +2620,8 @@ var SharedAcpRuntime = class { } ensureSession(input) { this.assertOpen(); - refuseSharedHasExplicitModel(input); + // XMD requested model: shared sessions refuse this in-process input. + refuseSharedRequestedModel(input); if (input.mode !== "persistent") invalidOption("Shared sessions require persistent mode; use the in-process runtime for oneshot sessions."); const session = this.sessionOptions(input); return this.track(ensureSession({ @@ -2631,7 +2639,8 @@ var SharedAcpRuntime = class { } startTurn(input) { this.assertOpen(); - refuseSharedHasExplicitModel(input); + // XMD requested model: shared sessions refuse this in-process input. + refuseSharedRequestedModel(input); if (input.mode !== "prompt" || input.assertActive || input.onPermissionRequest || input.onElicitation) invalidOption("Shared turns support prompt mode and the client's static permission policy; in-process callbacks and steering are unsupported."); if (!input.requestId.trim()) invalidOption("A shared turn requires a requestId."); const sessionId = sharedRecordId(input.handle); @@ -2779,7 +2788,8 @@ var SharedAcpRuntime = class { })); } async setConfigOption(input) { - refuseSharedHasExplicitModel(input); + // XMD requested model: shared sessions refuse this in-process input. + refuseSharedRequestedModel(input); return (await this.dispatchControl(input, "session/set_config_option", async (sessionId, assertAuthority) => { const configId = resolveSupportedConfigOptionId(await resolveSessionRecord(sessionId), input.key); assertAuthority(); @@ -2881,7 +2891,8 @@ var AcpxRuntime = class { } async ensureSession(input) { const { sessionName, agent } = this.resolveSessionIdentity(input); - const hasExplicitModel = hasExplicitModelOf(input); + // XMD requested model: validate before manager work begins. + const requestedModel = requestedModelOf(input); const record = await (await this.getManager()).ensureSession({ sessionKey: sessionName, agent, @@ -2890,7 +2901,8 @@ var AcpxRuntime = class { resumeSessionId: input.resumeSessionId, sessionOptions: input.sessionOptions, materialization: input.materialization, - hasExplicitModel + // XMD requested model: consumed only by this attaching client. + requestedModel }); return this.createSessionHandle({ ...input, @@ -2920,7 +2932,8 @@ var AcpxRuntime = class { } startTurn(input) { const { handle, state } = this.resolveManagerHandle(input.handle); - const hasExplicitModel = hasExplicitModelOf(input); + // XMD requested model: validate before manager work begins. + const requestedModel = requestedModelOf(input); const turnPromise = this.getManager().then((manager) => manager.startTurn({ handle, text: input.text, @@ -2933,7 +2946,8 @@ var AcpxRuntime = class { assertActive: input.assertActive, onElicitation: input.onElicitation, onPermissionRequest: input.onPermissionRequest, - hasExplicitModel + // XMD requested model: consumed only by this attaching client. + requestedModel })); turnPromise.catch(() => {}); const sessionMaterialization = turnPromise.then((turn) => turn.materialized); @@ -2960,7 +2974,8 @@ var AcpxRuntime = class { } async *runTurn(input) { const { handle, state } = this.resolveManagerHandle(input.handle); - const hasExplicitModel = hasExplicitModelOf(input); + // XMD requested model: validate before manager work begins. + const requestedModel = requestedModelOf(input); yield* (await this.getManager()).runTurn({ handle, text: input.text, @@ -2973,7 +2988,8 @@ var AcpxRuntime = class { assertActive: input.assertActive, onElicitation: input.onElicitation, onPermissionRequest: input.onPermissionRequest, - hasExplicitModel + // XMD requested model: consumed only by this attaching client. + requestedModel }); } async getCapabilities(input) { @@ -2995,8 +3011,9 @@ var AcpxRuntime = class { } async setConfigOption(input) { const { handle, state } = this.resolveManagerHandle(input.handle); - const hasExplicitModel = hasExplicitModelOf(input); - return await (await this.getManager()).setConfigOption(handle, input.key, input.value, state.mode, input, hasExplicitModel); + // XMD requested model: validate before manager work begins. + const requestedModel = requestedModelOf(input); + return await (await this.getManager()).setConfigOption(handle, input.key, input.value, state.mode, input, requestedModel); } async cancel(input) { const { handle } = this.resolveManagerHandle(input.handle); diff --git a/packages/cli/src/headless/host.ts b/packages/cli/src/headless/host.ts index 85c748f02..aa106b90c 100644 --- a/packages/cli/src/headless/host.ts +++ b/packages/cli/src/headless/host.ts @@ -458,7 +458,7 @@ export function* useHeadlessChildren( setups.set(identity, slot); return { sessionIdentity: identity, - hasExplicitModel: genuine.hasExplicitModel, + requestedModel: genuine.requestedModel, *activate(provider, agent, configuration) { setups.delete(identity); const accepted = yield* genuine.activate(provider, agent, configuration); diff --git a/packages/core/src/agent/provider-api.ts b/packages/core/src/agent/provider-api.ts index 3961169df..63347b9a5 100644 --- a/packages/core/src/agent/provider-api.ts +++ b/packages/core/src/agent/provider-api.ts @@ -220,7 +220,7 @@ function sessionSetupOf(value: unknown): AgentSessionSetup { const activate = Reflect.get(value, "activate"); const finish = Reflect.get(value, "finish"); const sessionIdentity = Reflect.get(value, "sessionIdentity"); - const hasExplicitModel = Reflect.get(value, "hasExplicitModel"); + const requestedModel = Reflect.get(value, "requestedModel"); if (typeof finish !== "function") { throw new AgentProviderInstallError( "the live agent provider installation answered a session setup that settles nothing", @@ -237,17 +237,20 @@ function sessionSetupOf(value: unknown): AgentSessionSetup { "conversation it names", ); } - if (typeof hasExplicitModel !== "boolean") { + if ( + !Reflect.has(value, "requestedModel") || + (requestedModel !== undefined && typeof requestedModel !== "string") + ) { // No inferred default: a provider that guessed would initialize a resumed // conversation under the wrong model preference. throw new AgentProviderInstallError( - "the live agent provider installation answered a session setup that does not say whether " + - "its request names a model", + "the live agent provider installation answered a session setup without a valid " + + "requested model", ); } return { ...(typeof sessionIdentity === "string" ? { sessionIdentity } : {}), - hasExplicitModel, + requestedModel, // Bound to the handle that answered, like `finish`: both are closures over // one issuance in the copy of core that opened it, and calling either with // any other receiver would reach a different copy's state or none. diff --git a/packages/core/src/agent/session-setup.ts b/packages/core/src/agent/session-setup.ts index a0eec77cc..d8a99dbad 100644 --- a/packages/core/src/agent/session-setup.ts +++ b/packages/core/src/agent/session-setup.ts @@ -67,13 +67,13 @@ export interface AgentSessionSetup { /** The engine-derived identity, when an element opened this request. */ readonly sessionIdentity?: string; /** - * Whether this setup's final request explicitly specifies a model. + * The exact model named by this setup's final request, or undefined. * * Describes this request only; it neither authorizes provider work nor says - * configuration has been applied. The requested value remains private until - * the existing configuration callback receives it. + * configuration has been applied. Only finish's configuration callback + * applies and verifies the requested settings. */ - readonly hasExplicitModel: boolean; + readonly requestedModel: string | undefined; /** * Authorize provider work; a retained conversation requires its configuration. * @@ -227,7 +227,7 @@ export function createSessionSetupOwner(): SessionSetupOwner { ...(requested.sessionIdentity === undefined ? {} : { sessionIdentity: requested.sessionIdentity }), - hasExplicitModel: requested.configuration?.model !== undefined, + requestedModel: requested.configuration?.model, *activate( provider: string, agentCommand: string, diff --git a/packages/core/tests/agent-session-setup.test.ts b/packages/core/tests/agent-session-setup.test.ts index 6ebfe8f14..fbedfe51a 100644 --- a/packages/core/tests/agent-session-setup.test.ts +++ b/packages/core/tests/agent-session-setup.test.ts @@ -262,26 +262,28 @@ describe("Tier SP — session setup ownership", () => { expect(refusalOf(() => owner.setup(rebuilt))).toContain("not a live session request"); }); - it("SP12: a setup says whether its final request names a model, and nothing more", function* () { + it("SP12: a setup exposes the exact model from its final request", function* () { const owner = createSessionSetupOwner(); const presence = (configuration: SessionConfiguration | undefined, routed = false) => { const issuance = openSessionRequest(`expansion:${String(configuration?.model)}`, "review"); issuance.configure(configuration); // A handler's whole-request replacement is the request the provider - // receives, so the fact follows that final request. + // receives, so the requested model follows that final request. const request = routed ? issuance.request.with({ name: "elsewhere" }) : issuance.request; const setup = owner.setup(request); - return { hasExplicitModel: setup.hasExplicitModel, keys: Object.keys(setup).sort() }; + return { requestedModel: setup.requestedModel, keys: Object.keys(setup).sort() }; }; - expect(presence({ model: "gpt-5.4" }).hasExplicitModel).toBe(true); - expect(presence({ model: "gpt-5.4", effort: "high" }, true).hasExplicitModel).toBe(true); - expect(presence({ effort: "high" }).hasExplicitModel).toBe(false); - expect(presence(undefined).hasExplicitModel).toBe(false); - // The value itself stays sealed: a setup carries the fact, not the model. + expect(presence({ model: "gpt-5.4" }).requestedModel).toBe("gpt-5.4"); + expect(presence({ model: "claude-fable-5", effort: "high" }, true).requestedModel).toBe( + "claude-fable-5", + ); + expect(presence({ effort: "high" }).requestedModel).toBe(undefined); + expect(presence(undefined).requestedModel).toBe(undefined); + // The model is visible; configuration still belongs to finish. expect(presence({ model: "gpt-5.4" }).keys).toEqual([ "activate", "finish", - "hasExplicitModel", + "requestedModel", "sessionIdentity", ]); }); diff --git a/packages/core/tests/root-provider.test.ts b/packages/core/tests/root-provider.test.ts index 3417b2d9a..77d982f61 100644 --- a/packages/core/tests/root-provider.test.ts +++ b/packages/core/tests/root-provider.test.ts @@ -246,7 +246,7 @@ describe("Tier RP — what the delivery adapter carries to a provider", () => { */ class DeliveredSetup { readonly #asked: Asked[]; - readonly hasExplicitModel = false; + readonly requestedModel = undefined; constructor(asked: Asked[]) { this.#asked = asked; @@ -330,18 +330,18 @@ describe("Tier RP — what the delivery adapter carries to a provider", () => { ]); }); - it("SC5: a delivered setup must say whether its request names a model", function* () { - for (const presence of [undefined, "true", 1]) { + it("SC5: a delivered setup carries a valid requested model member", function* () { + for (const presence of [undefined, null, true, 1]) { const issuance = openSessionRequest("expansion:1", "review"); let refusal: Error | undefined; - let forwarded: boolean | undefined; + let forwarded: string | undefined; yield* scoped(function* () { yield* registerAgentProvider( "probe", // deno-lint-ignore require-yield function* (_options, launchCoordinator): Operation { try { - forwarded = launchCoordinator.sessionSetup(issuance.request).hasExplicitModel; + forwarded = launchCoordinator.sessionSetup(issuance.request).requestedModel; } catch (error) { refusal = error instanceof Error ? error : new Error(String(error)); } @@ -353,7 +353,11 @@ describe("Tier RP — what the delivery adapter carries to a provider", () => { coordinatorAnswering(() => { const handle = new DeliveredSetup([]); // An untyped embedder's answer, reached without a cast. - Reflect.defineProperty(handle, "hasExplicitModel", { value: presence }); + if (presence === undefined) { + Reflect.deleteProperty(handle, "requestedModel"); + } else { + Reflect.defineProperty(handle, "requestedModel", { value: presence }); + } return handle; }), ); @@ -361,18 +365,18 @@ describe("Tier RP — what the delivery adapter carries to a provider", () => { // No fallback guesses a missing or malformed fact. expect(forwarded).toBe(undefined); expect(refusal).toBeInstanceOf(AgentProviderInstallError); - expect(refusal?.message).toContain("names a model"); + expect(refusal?.message).toContain("requested model"); } - // A real boolean crosses unchanged. + // The exact requested model crosses unchanged. const issuance = openSessionRequest("expansion:1", "review"); - let forwarded: boolean | undefined; + let forwarded: string | undefined; yield* scoped(function* () { yield* registerAgentProvider( "probe", // deno-lint-ignore require-yield function* (_options, launchCoordinator): Operation { - forwarded = launchCoordinator.sessionSetup(issuance.request).hasExplicitModel; + forwarded = launchCoordinator.sessionSetup(issuance.request).requestedModel; }, ); yield* installAgentProvider( @@ -380,12 +384,12 @@ describe("Tier RP — what the delivery adapter carries to a provider", () => { OPTIONS, coordinatorAnswering(() => { const handle = new DeliveredSetup([]); - Reflect.defineProperty(handle, "hasExplicitModel", { value: true }); + Reflect.defineProperty(handle, "requestedModel", { value: "claude-exact-model" }); return handle; }), ); }); - expect(forwarded).toBe(true); + expect(forwarded).toBe("claude-exact-model"); }); it("SC5: a delivered setup that authorizes nothing is refused", function* () { @@ -409,7 +413,7 @@ describe("Tier RP — what the delivery adapter carries to a provider", () => { OPTIONS, coordinatorAnswering(() => { const handle: AgentSessionSetup = { - hasExplicitModel: false, + requestedModel: undefined, // deno-lint-ignore require-yield *activate(): Operation> { return Ok(undefined); diff --git a/scripts/tests/acpx-vendor.test.ts b/scripts/tests/acpx-vendor.test.ts index c626d19c6..344bd3c25 100644 --- a/scripts/tests/acpx-vendor.test.ts +++ b/scripts/tests/acpx-vendor.test.ts @@ -40,7 +40,7 @@ const SEAMS: readonly string[] = [ "Strict explicit resume:", "XMD prompt diagnostic:", "XMD prompt usage:", - "hasExplicitModel", + "XMD requested model:", "XMD exact-model replay:", ]; diff --git a/specs/acp-client-spec.md b/specs/acp-client-spec.md index 74e99028d..5d7f173c5 100644 --- a/specs/acp-client-spec.md +++ b/specs/acp-client-spec.md @@ -455,23 +455,25 @@ history. A provider that cannot reconfigure that conversation in place refuses. A provider that releases its handle after each owned operation reconnects for the next one, and a reconnect initializes the resumed conversation before the -steps above can run. Each attaching call therefore states, for itself only, -whether the use it belongs to names a model — `hasExplicitModel` on the ensure, -turn and configuration-write inputs of the in-process runtime. Eager existing -resolution takes it from its setup; a prompt or launch from its final authentic -configuration; a retained launch's reacquisition from its retained -configuration. A configuration attempt's restoring writes carry the same fact -as its ordinary ones. Discovery and unconfigured or effort-only uses omit it, -and omission is false. - -The fact reaches only the client constructions that call itself performs. It is -never saved in creation options, records, routes, journals, encoded handles or -any provider state, so a later operation supplies its own; a retained live -client is not reinitialized for a later use. A non-boolean value refuses, and a -shared runtime refuses `true` before any effect. A Claude `session/load` or -`session/resume` for a call that names a model carries -`_meta["executablemd.has-explicit-model/v1"]: true`; `session/new` and other -adapters carry nothing. +steps above can run. Each attaching call therefore carries its use's explicitly +requested model as +`requestedModel` on the ensure, turn and configuration-write inputs of the +in-process runtime. Eager existing resolution takes the exact string from its +setup; a prompt or launch from its final authentic configuration; a retained +launch's reacquisition from its retained configuration. Ordinary and restoring +writes carry the same request's model. Discovery and unconfigured or effort-only +uses omit it. + +The requested value reaches only the client constructions that call itself +performs. It neither applies configuration nor replaces the resumed model +observation. It is never saved in creation options, records, routes, journals, +encoded handles or provider state; a later operation supplies its own value, +and a retained live client is not reinitialized for a later use. A supplied +value must be a non-empty string. A shared runtime refuses a supplied model +before any effect. On Claude `session/load` or `session/resume`, the client +checks `requestedModel !== undefined` and derives the existing +`_meta["executablemd.has-explicit-model/v1"]: true` marker. The model string +stays within the runtime; `session/new` and other adapters carry no marker. On such a resume the prepared Claude adapter reasserts no environment or settings model. It reads the model the resumed backend reports and advertises @@ -578,7 +580,7 @@ interface ProviderSessionConfiguration { interface AgentSessionSetup { readonly sessionIdentity?: string; - readonly hasExplicitModel: boolean; + readonly requestedModel: string | undefined; activate( provider: string, agentCommand: string, @@ -633,15 +635,16 @@ conversation's scope. `sessionSetup()` accepts one exact live opaque session request. Its handle exposes the optional durable component identity, authorizes provider work once, -and settles that request once. `hasExplicitModel` says whether that final -request's configuration names a model: true for `model` alone or with -`effort`, false for omission and `effort` alone. It describes the request only — -it authorizes nothing, says nothing has been applied, and carries no value; the -requested settings stay private until `finish()` hands them to `configure`. It -exists so an eager reattachment can initialize correctly before `finish()` -(§Session configuration). The structural adapter forwards a real boolean and -refuses a setup that supplies anything else; public request middleware receives -no such member. For a new Session, `finish()` issues the +and settles that request once. `requestedModel` is the exact model string named +by that final +request's configuration, or `undefined` for omission and effort-only requests. +It describes the request only: it authorizes nothing and does not claim that +configuration has been applied. `finish()` still hands the requested settings +to `configure` for application and verification. Exposing the requested model +lets an eager reattachment choose continuation behavior before `finish()` +(§Session configuration). The structural adapter requires the member, forwards +a string or `undefined`, and refuses a missing member or another value type; +public request middleware receives no such member. For a new Session, `finish()` issues the configured use without reading or writing provider configuration. For an existing Session, it invokes the provider's stable `configure` operation, requires the returned canonical value to equal the requested members exactly, @@ -1716,7 +1719,7 @@ its caller supplied, from Prompt `timeout` or the enclosing AgentProvider one: it takes ownership where the agent needs it, reconciles the retained route, ensures through the bound runtime, validates the exact identity and host mapping, releases the handle, and starts no turn. That ensure carries - its setup's `hasExplicitModel` (§A configured conversation that reconnects). + its setup's `requestedModel` (§A configured conversation that reconnects). Directory resolution classifies a record still carrying ACPX's own pending marker as new, and a record without one — including every record written