diff --git a/packages/acp/src/provider.ts b/packages/acp/src/provider.ts index dca8f85c4..740cbf233 100644 --- a/packages/acp/src/provider.ts +++ b/packages/acp/src/provider.ts @@ -516,6 +516,17 @@ 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. + * + * Omitted for an unconfigured or effort-only use. + */ +function modelRequest(configuration: SessionConfiguration | undefined): { + requestedModel?: string; +} { + return configuration?.model === undefined ? {} : { requestedModel: configuration.model }; +} + function asName(routed: string | Session | undefined): string | undefined { return typeof routed === "string" ? routed : undefined; } @@ -1803,6 +1814,11 @@ function* useAcpxProviderState( * acceptance rather than after this ensure. */ deferEstablished?: boolean; + /** + * 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. + */ + requestedModel?: string; } function* ensureFromPrepared( @@ -1840,6 +1856,7 @@ function* useAcpxProviderState( ...(intent.materialization === true ? { materialization: "first-turn-acceptance" as const } : {}), + ...(intent.requestedModel === undefined ? {} : { requestedModel: intent.requestedModel }), }, (handle, entry) => { // The exact value this provider already issued, when it issued one. @@ -2611,6 +2628,7 @@ function* useAcpxProviderState( agentName: string, entry: ManagedSession, requireWrite: boolean, + configuration?: SessionConfiguration, ): ConfigurationTarget { const runtime = entry.runtime.runtime; const readStatus = runtime.getStatus; @@ -2664,6 +2682,9 @@ function* useAcpxProviderState( handle: entry.handle, key: optionId, value, + // The same attempt's requested model for ordinary and restoring + // writes alike: either may be the one that reconnects. + ...modelRequest(configuration), }) .then(() => undefined); yield* until(pending); @@ -2718,7 +2739,7 @@ function* useAcpxProviderState( configuration: SessionConfiguration, ): Operation { const selectors = yield* applySessionConfiguration( - configurationTarget(agentName, entry, true), + configurationTarget(agentName, entry, true, configuration), configuration, ); return { @@ -2896,6 +2917,7 @@ function* useAcpxProviderState( ...(construction === undefined ? {} : { build: construction.build }), ...(resumeSessionId === undefined ? {} : { attachment: { resumeSessionId } }), state, + ...modelRequest(desired), materialization: constructing && adapterFor(agentName)?.acceptance === "first-turn-acceptance", deferEstablished: @@ -2951,6 +2973,7 @@ function* useAcpxProviderState( mode: "prompt", requestId, timeoutMs, + ...modelRequest(desired), ...(callbacks === undefined ? {} : { @@ -3643,6 +3666,7 @@ function* useAcpxProviderState( ...(bound === undefined ? {} : { build: bound.build }), attachment: { resumeSessionId: prepared.nativeSessionId }, state: "existing", + ...modelRequest(requested), }); } catch (error) { if (error instanceof AttachmentRefused) { @@ -3812,6 +3836,7 @@ function* useAcpxProviderState( text: plan.prompt, mode: "prompt", requestId: plan.requestId, + ...modelRequest(retainedConfiguration(prepared)), }); activeTurns.add(turn); @@ -4647,6 +4672,9 @@ function* useAcpxProviderState( ...(construction === undefined ? {} : { build: construction.build }), ...(resumeSessionId === undefined ? {} : { attachment: { resumeSessionId } }), state, + ...(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/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..3a77ec612 --- /dev/null +++ b/packages/acp/tests/claude-reconnect.test.ts @@ -0,0 +1,704 @@ +/** + * 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, requestedModel?: string): AcpRuntimeEnsureInput { + return { + sessionKey: KEY, + agent: "claude", + mode: "persistent", + cwd: world.root, + ...(requestedModel === undefined ? {} : { requestedModel }), + }; +} + +/** One owned prompt the way the provider runs it: ensure, turn, release. */ +function* ownedTurn( + world: World, + runtime: Runtime, + text: string, + requestedModel?: string, +): Operation<{ status: string }> { + return yield* scoped(function* () { + const handle = yield* until(runtime.ensureSession(input(world, requestedModel))); + yield* ensure(() => + until(runtime.close({ handle, reason: "released after the owned prompt" })), + ); + const turn = runtime.startTurn({ + handle, + text, + mode: "prompt", + requestId: text, + ...(requestedModel === undefined ? {} : { requestedModel }), + }); + 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, + requestedModel?: string, +): Operation { + return yield* scoped(function* () { + 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, + ...(requestedModel === undefined ? {} : { requestedModel }), + }), + ); + 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, model); + if (effort !== undefined) { + yield* ownedWrite(world, runtime, "effort", effort, model); + } + yield* ownedTurn(world, runtime, "first", model); +} + +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, EXPLICIT)).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", EXPLICIT); + const before = (yield* logged(world)).length; + + // A cold control reconnect for another setting writes no model. + 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, 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", 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([ + [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 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", EXPLICIT); + const durable = JSON.stringify([...world.records.values()]); + expect(durable).not.toContain("has-explicit-model"); + 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, EXPLICIT))); + expect(JSON.stringify(handle)).not.toContain("xplicit"); + yield* until(runtime.close({ handle, reason: "inspected" })); + + // Malformed values refuse before any native work. + const starts = (yield* logged(world)).length; + 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); + } + }); + }); + + 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", 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. + 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", "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", "fable"); + 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", EXPLICIT); + 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 = (requestedModel?: string): AcpRuntimeEnsureInput => ({ + sessionKey: "wire", + agent: "agent", + mode: "persistent", + cwd: root, + ...(requestedModel === undefined ? {} : { requestedModel }), + }); + // Created under a flagged call: session/new never carries it. + const created = yield* until(runtime.ensureSession(ensureInput(EXPLICIT))); + yield* until(runtime.close({ handle: created, reason: "release" })); + 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(requestedModel)}`, + ...(requestedModel === undefined ? {} : { requestedModel }), + }); + 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", + requestedModel: EXPLICIT, + }), + ), + ).toContain("not supported by shared sessions"); + expect( + refusal(() => + shared.startTurn({ + handle, + text: "x", + mode: "prompt", + requestId: "x", + requestedModel: EXPLICIT, + }), + ), + ).toContain("not supported by shared sessions"); + const write = yield* until( + shared.setConfigOption({ handle, key: "model", value: "m", requestedModel: EXPLICIT }).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, + requestedModel: string, +): Operation { + return yield* scoped(function* () { + const handle = yield* until(runtime.ensureSession(input(world, requestedModel))); + yield* ensure(() => until(runtime.close({ handle, reason: "refused" }))); + try { + const turn = runtime.startTurn({ + handle, + text, + mode: "prompt", + requestId: text, + requestedModel, + }); + 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..1c812d43f 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 requested model travelled with exactly the calls it belongs to. + */ + modelRequests: string[]; closeCalls: AcpRuntimeHandle[]; /** * Every close, whole. @@ -368,6 +375,7 @@ export function createFakeRuntime(): FakeRuntimeHarness { doctorCalls: 0, ensureCalls: [], turns: [], + modelRequests: [], handleIds: [], startedTurns(count) { return { @@ -407,6 +415,7 @@ export function createFakeRuntime(): FakeRuntimeHarness { } harness.activity?.("ensure"); harness.ensureCalls.push(input); + 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 @@ -461,6 +470,7 @@ export function createFakeRuntime(): FakeRuntimeHarness { return gate ? run(() => gate).then(answer) : Promise.resolve(answer()); }, startTurn(input) { + harness.modelRequests.push(`turn ${input.requestedModel ?? "-"}`); 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; + requestedModel?: string; + }) { + 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 97c8f4076..e0ce0dcc4 100644 --- a/packages/acp/tests/loaded-copy.test.ts +++ b/packages/acp/tests/loaded-copy.test.ts @@ -161,8 +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 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 } : {}), + 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 @@ -329,6 +339,7 @@ function foreignCoordinator(owner: ForeignOwner, log: SessionSetupLog): AgentLau const setup = owner.setup(request); const identity = setup.sessionIdentity; const observed: AgentSessionSetup = { + 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 d31df25d1..90f07a39a 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 }), + 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 @@ -5085,6 +5086,13 @@ 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.modelRequests).toEqual([ + "ensure -", + "set model gpt-5.4-mini", + "set effort gpt-5.4-mini", + ]); }); }); @@ -5207,6 +5215,13 @@ describe("Tier NC — configured native launch", () => { "spawn", "exited", ]); + // The owed turn is spent under the launch's own configured use. + expect(harness.modelRequests).toEqual([ + "ensure -", + "set model gpt-5.4-mini", + "set effort gpt-5.4-mini", + "turn gpt-5.4-mini", + ]); }); }); @@ -5319,6 +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 requested model. + expect(harness.modelRequests).toEqual([ + "ensure gpt-5.4-mini", + "set model gpt-5.4-mini", + "set effort gpt-5.4-mini", + ]); }); }); @@ -5553,6 +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.modelRequests).toEqual([]); }); }); diff --git a/packages/acp/tests/provider.test.ts b/packages/acp/tests/provider.test.ts index 520b0dfd2..8288e0005 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 }), + 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 @@ -2929,6 +2930,52 @@ describe("Tier SE — reconfiguring an owned session", () => { }); }); + it("SE3: each reconnecting call carries only its own use's requested model", 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.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.modelRequests = []; + const plain = yield* Agent.operations.session("review"); + yield* collectPrompt("plain", { session: plain }); + 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.modelRequests = []; + yield* configuredSession(coordinator, "review", { effort: "low" }); + expect(harness.modelRequests).toEqual(["ensure -", "set effort -"]); + harness.modelRequests = []; + yield* configuredSession(coordinator, "review", { model: "gpt-5.4" }); + expect(harness.modelRequests).toEqual(["ensure gpt-5.4", "set model gpt-5.4"]); + + // A restoring write belongs to the attempt that made it. + 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.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", + ]); + }); + }); + 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..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": "dd0f2c28ee7aabf490a54e7779ac9863fde229f84296a4a051646afe40cf7be6" + "sha256": "f1b825fc9603337eba9d12d607127892ba0ff33c54a20066f7c72bdaaa300ac2" }, { "path": "generated/agent-registry-Cz9fD9H1.js", @@ -56,17 +56,17 @@ { "path": "generated/queue-owner-runtime-B0mPqOu4.js", "kind": "generated", - "sha256": "4f564876f38b978472f1e1a8f133db53a155f33614b193564a34d5e5b12069bc" + "sha256": "e2bd0fd71b49e51697d42e7f4dae7a97aee525dfe17ddbeab8efae2d93951b6c" }, { "path": "generated/runtime.d.ts", "kind": "generated", - "sha256": "d7dad6ef20bfbf94867dcd90751ffad58d58bc62cb12469ee5f1524283aa9c87" + "sha256": "e1e0669b91a84d25fbec7bdf37546ef4bd30e8e9475caffb005ad01d0246b305" }, { "path": "generated/runtime.js", "kind": "generated", - "sha256": "1daede0ebbdf54d2814ce594d695e8b6f1ade183506e09fce799b1bd574b9f4b" + "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 9efbce143..702a1ff8d 100644 --- a/packages/acp/vendor/acpx/PROVENANCE.md +++ b/packages/acp/vendor/acpx/PROVENANCE.md @@ -161,6 +161,48 @@ 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 `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) + +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..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,6 +3065,16 @@ var AcpClient = class { const { command, args } = resolveAgentCommandParts(this.options.agentCommand, this.options.agentArgv); return buildClaudeCodeOptionsMeta(this.options.sessionOptions, isClaudeAcpCommand(command, args)); } + /** 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.requestedModel === void 0 || !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() + // 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); } finally { @@ -3136,7 +3147,8 @@ var AcpClient = class { sessionId, cwd: sessionCwd, mcpServers: this.options.mcpServers ?? [], - _meta: this.sessionMeta() + // XMD requested model: continuation metadata belongs to this call. + _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..44f780864 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; + /** + * XMD requested model: the exact model requested by this call's use. + * + * 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. + */ + requestedModel?: string; }; type AcpRuntimeTurnAttachment = { /** @@ -113,6 +121,8 @@ type AcpRuntimeTurnInput = AcpControlAuthority & { onPermissionRequest?: AcpPermissionHandler; /** Handles ACP elicitation requests owned by this prompt turn. */ onElicitation?: AcpElicitationHandler; + /** 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 = { @@ -411,6 +421,8 @@ interface AcpRuntime { handle: AcpRuntimeHandle; key: string; value: string; + /** XMD requested model: the model requested by this write's use; see `AcpRuntimeEnsureInput.requestedModel`. */ + requestedModel?: string; }): Promise; doctor?(): Promise; cancel(input: { @@ -786,6 +798,8 @@ export declare class SharedAcpRuntime { handle: AcpRuntimeHandle; key: string; value: string; + /** XMD requested model: the model requested by this write's use; see `AcpRuntimeEnsureInput.requestedModel`. */ + requestedModel?: string; }): Promise; watchSession(input: { handle: AcpRuntimeHandle; @@ -870,6 +884,8 @@ export declare class AcpxRuntime implements AcpxRuntimeLike { handle: AcpRuntimeHandle; key: string; value: string; + /** 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 8739a3bd1..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) { + // 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,6 +1425,8 @@ var AcpRuntimeManager = class { saveRecord: async () => checkpoint.checkpoint(), createClient: (options) => controlClient = this.createClient({ ...options, + // XMD requested model: only this cold control client receives it. + requestedModel, processLifecycle: this.options.processLifecycle, processLaunchScope: { kind: "runtime-session", @@ -1611,7 +1614,9 @@ var AcpRuntimeManager = class { sessionKey: input.sessionKey }, verbose: this.options.verbose, - sessionOptions: input.sessionOptions + sessionOptions: input.sessionOptions, + // XMD requested model: the call, not the session record. + requestedModel: input.requestedModel }); const owner = this.createSessionOwner({ client, @@ -1857,7 +1862,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); + // 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, @@ -1965,7 +1971,8 @@ var AcpRuntimeManager = class { owner.bufferSessionUpdates = false; this.drainPendingSessionUpdates(owner); } - createTurnClient(record) { + // XMD requested model: a cold turn uses its own request. + createTurnClient(record, requestedModel) { return this.createClient({ agentCommand: record.agentCommand, agentArgv: record.agentArgv, @@ -1986,7 +1993,9 @@ var AcpRuntimeManager = class { sessionKey: record.name ?? record.acpxRecordId }, verbose: this.options.verbose, - sessionOptions: sessionOptionsFromRecord(record) + sessionOptions: sessionOptionsFromRecord(record), + // XMD requested model: consumed only by this attaching client. + requestedModel }); } buildRuntimeTurnController(task, turn) { @@ -2253,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) { + // 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), 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) { + 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); @@ -2280,7 +2290,8 @@ var AcpRuntimeManager = class { }, { replacingConfigOption: { key, resolve: (connectedRecord) => resolveSupportedConfigOptionId(connectedRecord, key) - } }, authority); + // XMD requested model: this control may construct the attaching client. + } }, authority, requestedModel); } async cancel(handle) { await this.activeControllers.get(handle.acpxRecordId ?? handle.sessionKey)?.requestCancelActivePrompt(); @@ -2504,6 +2515,16 @@ const SHARED_BACKEND = "acpx-shared"; function invalidOption(message) { throw new AcpRuntimeError("ACP_INVALID_RUNTIME_OPTION", message); } +/** 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; +} +/** 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 { backend: SHARED_BACKEND, @@ -2599,6 +2620,8 @@ var SharedAcpRuntime = class { } ensureSession(input) { this.assertOpen(); + // 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({ @@ -2616,6 +2639,8 @@ var SharedAcpRuntime = class { } startTurn(input) { this.assertOpen(); + // 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); @@ -2763,6 +2788,8 @@ var SharedAcpRuntime = class { })); } async setConfigOption(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(); @@ -2864,6 +2891,8 @@ var AcpxRuntime = class { } async ensureSession(input) { const { sessionName, agent } = this.resolveSessionIdentity(input); + // XMD requested model: validate before manager work begins. + const requestedModel = requestedModelOf(input); const record = await (await this.getManager()).ensureSession({ sessionKey: sessionName, agent, @@ -2871,7 +2900,9 @@ var AcpxRuntime = class { cwd: input.cwd ?? this.options.cwd, resumeSessionId: input.resumeSessionId, sessionOptions: input.sessionOptions, - materialization: input.materialization + materialization: input.materialization, + // XMD requested model: consumed only by this attaching client. + requestedModel }); return this.createSessionHandle({ ...input, @@ -2901,6 +2932,8 @@ var AcpxRuntime = class { } startTurn(input) { const { handle, state } = this.resolveManagerHandle(input.handle); + // XMD requested model: validate before manager work begins. + const requestedModel = requestedModelOf(input); const turnPromise = this.getManager().then((manager) => manager.startTurn({ handle, text: input.text, @@ -2912,7 +2945,9 @@ var AcpxRuntime = class { signal: input.signal, assertActive: input.assertActive, onElicitation: input.onElicitation, - onPermissionRequest: input.onPermissionRequest + onPermissionRequest: input.onPermissionRequest, + // XMD requested model: consumed only by this attaching client. + requestedModel })); turnPromise.catch(() => {}); const sessionMaterialization = turnPromise.then((turn) => turn.materialized); @@ -2939,6 +2974,8 @@ var AcpxRuntime = class { } async *runTurn(input) { const { handle, state } = this.resolveManagerHandle(input.handle); + // XMD requested model: validate before manager work begins. + const requestedModel = requestedModelOf(input); yield* (await this.getManager()).runTurn({ handle, text: input.text, @@ -2950,7 +2987,9 @@ var AcpxRuntime = class { signal: input.signal, assertActive: input.assertActive, onElicitation: input.onElicitation, - onPermissionRequest: input.onPermissionRequest + onPermissionRequest: input.onPermissionRequest, + // XMD requested model: consumed only by this attaching client. + requestedModel }); } async getCapabilities(input) { @@ -2972,7 +3011,9 @@ 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); + // 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/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..aa106b90c 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, + 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 a9763994e..63347b9a5 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 requestedModel = Reflect.get(value, "requestedModel"); if (typeof finish !== "function") { throw new AgentProviderInstallError( "the live agent provider installation answered a session setup that settles nothing", @@ -236,8 +237,20 @@ function sessionSetupOf(value: unknown): AgentSessionSetup { "conversation it names", ); } + 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 without a valid " + + "requested model", + ); + } return { ...(typeof sessionIdentity === "string" ? { sessionIdentity } : {}), + 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 0d78515f8..d8a99dbad 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; + /** + * 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. Only finish's configuration callback + * applies and verifies the requested settings. + */ + readonly requestedModel: string | undefined; /** * Authorize provider work; a retained conversation requires its configuration. * @@ -219,6 +227,7 @@ export function createSessionSetupOwner(): SessionSetupOwner { ...(requested.sessionIdentity === undefined ? {} : { sessionIdentity: requested.sessionIdentity }), + 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 2ea2c6cc0..fbedfe51a 100644 --- a/packages/core/tests/agent-session-setup.test.ts +++ b/packages/core/tests/agent-session-setup.test.ts @@ -262,6 +262,32 @@ describe("Tier SP — session setup ownership", () => { expect(refusalOf(() => owner.setup(rebuilt))).toContain("not a live session request"); }); + 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 requested model follows that final request. + const request = routed ? issuance.request.with({ name: "elsewhere" }) : issuance.request; + const setup = owner.setup(request); + return { requestedModel: setup.requestedModel, keys: Object.keys(setup).sort() }; + }; + 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", + "requestedModel", + "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..77d982f61 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 requestedModel = undefined; constructor(asked: Asked[]) { this.#asked = asked; @@ -329,6 +330,68 @@ describe("Tier RP — what the delivery adapter carries to a provider", () => { ]); }); + 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: string | undefined; + yield* scoped(function* () { + yield* registerAgentProvider( + "probe", + // deno-lint-ignore require-yield + function* (_options, launchCoordinator): Operation { + try { + forwarded = launchCoordinator.sessionSetup(issuance.request).requestedModel; + } 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. + if (presence === undefined) { + Reflect.deleteProperty(handle, "requestedModel"); + } else { + Reflect.defineProperty(handle, "requestedModel", { value: presence }); + } + return handle; + }), + ); + }); + // No fallback guesses a missing or malformed fact. + expect(forwarded).toBe(undefined); + expect(refusal).toBeInstanceOf(AgentProviderInstallError); + expect(refusal?.message).toContain("requested model"); + } + + // The exact requested model crosses unchanged. + const issuance = openSessionRequest("expansion:1", "review"); + let forwarded: string | undefined; + yield* scoped(function* () { + yield* registerAgentProvider( + "probe", + // deno-lint-ignore require-yield + function* (_options, launchCoordinator): Operation { + forwarded = launchCoordinator.sessionSetup(issuance.request).requestedModel; + }, + ); + yield* installAgentProvider( + "probe", + OPTIONS, + coordinatorAnswering(() => { + const handle = new DeliveredSetup([]); + Reflect.defineProperty(handle, "requestedModel", { value: "claude-exact-model" }); + return handle; + }), + ); + }); + expect(forwarded).toBe("claude-exact-model"); + }); + it("SC5: a delivered setup that authorizes nothing is refused", function* () { const issuance = openSessionRequest("expansion:1", "review"); let refusal: Error | undefined; @@ -350,6 +413,7 @@ describe("Tier RP — what the delivery adapter carries to a provider", () => { OPTIONS, coordinatorAnswering(() => { const handle: AgentSessionSetup = { + 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 7bb786c3a..344bd3c25 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:", + "XMD requested model:", + "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..5d7f173c5 100644 --- a/specs/acp-client-spec.md +++ b/specs/acp-client-spec.md @@ -451,6 +451,46 @@ 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 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 +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 +580,7 @@ interface ProviderSessionConfiguration { interface AgentSessionSetup { readonly sessionIdentity?: string; + readonly requestedModel: string | undefined; activate( provider: string, agentCommand: string, @@ -594,7 +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. 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, @@ -1668,7 +1718,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 `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 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