From d8fd0faf96844f7998e77c989223ea3be85649e0 Mon Sep 17 00:00:00 2001 From: OmarAlex24 Date: Sun, 15 Nov 2026 19:24:55 +0000 Subject: [PATCH 1/4] fix(sandbox): a connecting sandbox chat's picker shows its own model and keeps its effort Co-Authored-By: Claude Opus 5.5 --- .../src/state/sandboxMirror.test.ts | 55 +++++++++++++++---- .../client-runtime/src/state/sandboxMirror.ts | 45 +++++++++++++-- 2 files changed, 86 insertions(+), 14 deletions(-) diff --git a/packages/client-runtime/src/state/sandboxMirror.test.ts b/packages/client-runtime/src/state/sandboxMirror.test.ts index 5a9bd2b065392..5a0fb974530cc 100644 --- a/packages/client-runtime/src/state/sandboxMirror.test.ts +++ b/packages/client-runtime/src/state/sandboxMirror.test.ts @@ -7,6 +7,10 @@ import { type SandboxThreadMirror, type ServerProvider, } from "@t3tools/contracts"; +import { + buildExplicitProviderOptionSelectionsFromDescriptors, + getProviderOptionDescriptors, +} from "@t3tools/shared/model"; import { describe, expect, it } from "vite-plus/test"; import { @@ -99,16 +103,47 @@ describe("sandboxMirrorPickerSource", () => { }); }); - // Picking the host's Claude on a stopped sandbox's mirror was refused by the host. - it("never offers the host's own providers while neither has read the sandbox", () => { - expect(sandboxMirrorPickerSource(input)).toEqual({ - providers: [bridge], - modelSelection: mirrorSelection, - readOnly: true, - }); - expect( - sandboxMirrorPickerSource({ ...input, hostMirror: { ...hostMirror, provider: null } }), - ).toEqual({ providers: [bridge], modelSelection: mirrorSelection, readOnly: true }); + // Picking the host's Claude on a stopped sandbox's mirror was refused by the host. The bridge's + // own list (its probe's placeholder, or another chat's models) showed "Sandbox agent", and a send + // derived the mirror's options against it, dropping the sandbox's effort. + it("offers only the mirror's own model and options while neither has read the sandbox", () => { + const placeholderBridge = { + ...bridge, + models: [{ slug: "sandbox", name: "Sandbox agent", isCustom: false, capabilities: null }], + } as ServerProvider; + const picked = { + ...mirrorSelection, + options: [ + { id: "effort", value: "xhigh" }, + { id: "fastMode", value: true }, + ], + } as ModelSelection; + for (const hostMirrorRead of [null, { ...hostMirror, provider: null }]) { + const source = sandboxMirrorPickerSource({ + ...input, + hostMirror: hostMirrorRead, + mirrorSelection: picked, + hostProviders: [sandboxClaude, placeholderBridge, hostCodex], + }); + expect(source.readOnly).toBe(true); + expect(source.modelSelection).toEqual(picked); + expect(source.providers.map((provider) => provider.instanceId)).toEqual(["sandboxes"]); + const models = source.providers[0]?.models ?? []; + expect(models.map(({ slug, name }) => ({ slug, name }))).toEqual([ + { slug: "claude-opus-5-5", name: "Claude Opus 5.5" }, + ]); + const caps = models[0]?.capabilities ?? {}; + // What the composer sends: the options its descriptors keep. + expect( + buildExplicitProviderOptionSelectionsFromDescriptors( + getProviderOptionDescriptors({ caps, selections: picked.options }), + picked.options, + ), + ).toEqual(picked.options); + } + expect(sandboxMirrorPickerSource({ ...input, hostProviders: [hostCodex] }).providers).toEqual( + [], + ); }); }); diff --git a/packages/client-runtime/src/state/sandboxMirror.ts b/packages/client-runtime/src/state/sandboxMirror.ts index 9e49d9972d976..f67967bcc1369 100644 --- a/packages/client-runtime/src/state/sandboxMirror.ts +++ b/packages/client-runtime/src/state/sandboxMirror.ts @@ -5,12 +5,14 @@ import { ProviderInstanceId, type RunId, SANDBOX_BRIDGE_INSTANCE_ID, + SANDBOX_BRIDGE_MODEL, type SandboxRecord, type SandboxThreadMirror, type ServerProvider, type ThreadId, WS_METHODS, } from "@t3tools/contracts"; +import { formatModelSlugName } from "@t3tools/shared/model"; import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; @@ -68,10 +70,43 @@ export const sandboxMirrorModelSourceOf = (mirror: SandboxThreadMirror | null) = export const SANDBOX_MIRROR_MODELS_PENDING_REASON = "The sandbox's models show once it has been reached."; +// The sandbox's own labels are unknown here; these read as the providers name their efforts. +const CHOICE_LABELS: Readonly> = { xhigh: "Extra High" }; +const labelOf = (id: string) => + CHOICE_LABELS[id] ?? id.charAt(0).toUpperCase() + id.slice(1).replace(/(?=[A-Z])/g, " "); + +/** + * The mirror's own pick as the only model its bridge offers, each option a one-choice control at + * its value, so the picker names the sandbox's model and a send keeps the sandbox's options. + */ +const mirrorPickModel = (selection: ModelSelection): ServerProvider["models"][number] => ({ + slug: selection.model, + name: + selection.model === SANDBOX_BRIDGE_MODEL + ? "Sandbox default" + : formatModelSlugName(selection.model), + isCustom: false, + capabilities: { + optionDescriptors: (selection.options ?? []).map(({ id, value }) => + typeof value === "boolean" + ? { id, label: labelOf(id), type: "boolean" as const, currentValue: value } + : { + id, + label: labelOf(id), + type: "select" as const, + options: [{ id: value, label: labelOf(value), isDefault: true }], + currentValue: value, + ...(id === "effort" ? { role: "reasoning" as const } : {}), + }, + ), + }, +}); + /** * Every picker on a sandbox chat's mirror: the sandbox's own thread where this client is paired - * with it, else what the host last read of it. With neither, the mirror's pick on the bridge alone, - * read-only: the bridge's shared list is another chat's, and the host refuses any other provider. + * with it, else what the host last read of it. With neither, the mirror's own pick alone, + * read-only: the bridge's shared list is its placeholder or another chat's, and the host refuses + * any other provider. */ export const sandboxMirrorPickerSource = (input: { readonly paired: ReturnType; @@ -87,8 +122,10 @@ export const sandboxMirrorPickerSource = (input: { return source !== null ? { ...source, readOnly: false } : { - providers: input.hostProviders.filter( - (provider) => provider.instanceId === bridgeInstanceId, + providers: input.hostProviders.flatMap((provider) => + provider.instanceId === bridgeInstanceId + ? [{ ...provider, models: [mirrorPickModel(input.mirrorSelection)] }] + : [], ), modelSelection: input.mirrorSelection, readOnly: true, From b7e45bc276cfce2b8a1afe977a64887c141f5d0d Mon Sep 17 00:00:00 2001 From: OmarAlex24 Date: Sun, 15 Nov 2026 19:24:55 +0000 Subject: [PATCH 2/4] fix(sandbox): the bridge keeps the sandbox's options a host pick leaves out Co-Authored-By: Claude Opus 5.5 --- packages/sandbox-bridge/src/bridge.test.ts | 44 ++++++++++++++++++ packages/sandbox-bridge/src/bridge.ts | 52 ++++++++++++++++------ 2 files changed, 83 insertions(+), 13 deletions(-) diff --git a/packages/sandbox-bridge/src/bridge.test.ts b/packages/sandbox-bridge/src/bridge.test.ts index e094f02585d51..56bdbc5e34241 100644 --- a/packages/sandbox-bridge/src/bridge.test.ts +++ b/packages/sandbox-bridge/src/bridge.test.ts @@ -652,6 +652,50 @@ describe("the sandbox's models", () => { codexSelection("gpt-5.5-mini"), ); }); + + // A host pick that only spelled options differently, or left them out, reset the sandbox's effort. + it("compares options by meaning and keeps the sandbox's for any the host leaves out", () => { + const fastMode = { id: "fastMode", label: "Fast", type: "boolean" } as const; + const capabilities = { optionDescriptors: [effort, fastMode] }; + const providers = [ + { + instanceId: "codex", + models: [ + { slug: "gpt-5.5", name: "GPT-5.5", isCustom: false, capabilities }, + { slug: "gpt-5.5-pro", name: "GPT-5.5 Pro", isCustom: false, capabilities }, + ], + }, + ] as unknown as SandboxSession["providers"]; + const high = { id: "effort", value: "high" }; + const current = codexSelection("gpt-5.5", [high]); + for (const host of [ + bridgeSelection("gpt-5.5", [high, { id: "fastMode", value: false }]), + bridgeSelection("gpt-5.5"), + bridgeSelection("gpt-5.5", [{ id: "fastMode", value: false }]), + ]) { + assert.isNull(sandboxSelectionFor(host, current, providers)); + } + assert.deepStrictEqual( + sandboxSelectionFor(bridgeSelection("gpt-5.5-pro"), current, providers), + codexSelection("gpt-5.5-pro", [high]), + ); + assert.deepStrictEqual( + sandboxSelectionFor( + bridgeSelection("gpt-5.5", [{ id: "fastMode", value: true }]), + current, + providers, + ), + codexSelection("gpt-5.5", [{ id: "fastMode", value: true }, high]), + ); + assert.deepStrictEqual( + sandboxSelectionFor( + bridgeSelection("gpt-5.5", [{ id: "effort", value: "medium" }]), + current, + providers, + ), + codexSelection("gpt-5.5", [{ id: "effort", value: "medium" }]), + ); + }); }); describe("forwarding a turn", () => { diff --git a/packages/sandbox-bridge/src/bridge.ts b/packages/sandbox-bridge/src/bridge.ts index 81b5b8d7467f9..ddfc006a5b0ca 100644 --- a/packages/sandbox-bridge/src/bridge.ts +++ b/packages/sandbox-bridge/src/bridge.ts @@ -307,22 +307,32 @@ export const withConfigValue = ( : option, ); -const optionKey = (selection: ModelSelection) => +type OptionDescriptors = NonNullable< + NonNullable["optionDescriptors"] +>; + +const defaultOptionValue = (descriptor: OptionDescriptors[number]) => + descriptor.type === "boolean" + ? (descriptor.currentValue ?? false) + : choiceOf(descriptor, undefined); + +// Options as the model runs them: one stated at its default reads as one left out. +const optionKey = (options: ModelSelection["options"], descriptors: OptionDescriptors) => JSON.stringify( - [...(selection.options ?? [])] + [...(options ?? [])] + .filter((option) => { + const descriptor = descriptors.find((candidate) => candidate.id === option.id); + return descriptor === undefined || defaultOptionValue(descriptor) !== option.value; + }) .map((option) => [option.id, option.value] as const) .toSorted(([left], [right]) => left.localeCompare(right)), ); -const sameSelection = (left: ModelSelection, right: ModelSelection) => - left.instanceId === right.instanceId && - left.model === right.model && - optionKey(left) === optionKey(right); - /** * The model to give the sandbox's thread for a message sent from the host: the one picked there, - * on the provider the sandbox's thread runs on. Null to keep the sandbox's own, as for the - * bridge's `sandbox` value, a model the sandbox does not list, or one it already has. + * on the provider the sandbox's thread runs on, with the sandbox's own value for any option the + * host leaves out. Null to keep the sandbox's own, as for the bridge's `sandbox` value, a model the + * sandbox does not list, or one it already runs the same way. */ export const sandboxSelectionFor = ( host: ModelSelection | null, @@ -338,13 +348,29 @@ export const sandboxSelectionFor = ( return null; } const provider = providers.find((candidate) => candidate.instanceId === sandbox.instanceId); - if (provider?.models.some((model) => model.slug === host.model) !== true) return null; - const next = { + const model = provider?.models.find((candidate) => candidate.slug === host.model); + if (model === undefined) return null; + const descriptors = model.capabilities?.optionDescriptors ?? []; + const hostOptions = host.options ?? []; + const options = [ + ...hostOptions, + ...(sandbox.options ?? []).filter( + (option) => + !hostOptions.some((stated) => stated.id === option.id) && + descriptors.some((descriptor) => descriptor.id === option.id), + ), + ]; + if ( + host.model === sandbox.model && + optionKey(options, descriptors) === optionKey(sandbox.options, descriptors) + ) { + return null; + } + return { instanceId: sandbox.instanceId, model: host.model, - ...(host.options === undefined || host.options.length === 0 ? {} : { options: host.options }), + ...(options.length === 0 ? {} : { options }), } as ModelSelection; - return sameSelection(next, sandbox) ? null : next; }; /** What a sandbox thread runs on when nothing names its model. */ From 515725ff8fcf3ea5f2e831ca4d7fde0e6f45e1ad Mon Sep 17 00:00:00 2001 From: OmarAlex24 Date: Sun, 15 Nov 2026 19:25:54 +0000 Subject: [PATCH 3/4] fix(sandbox): a sandbox chat opened before its sandbox answered still switches models Co-Authored-By: Claude Opus 5.5 --- .../orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts | 5 +++++ packages/provider-acp-registry/src/server/adapter.ts | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index ab5c56eee0577..ae912f52f5b6e 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -1316,6 +1316,11 @@ describe("AcpRegistryAdapterV2", () => { "fixture-agent", SANDBOX_BRIDGE_INSTANCE_ID, ); + // The bridge lists models only while the sandbox answers; a session opened without them + // still takes a model change. + assert.isTrue( + session.runtime.providerSession.capabilities.sessions.supportsModelSwitchInSession, + ); // Only a steer's restart says so; a detach (force-stop, session replacement) asks for no // runtime restart and is still a Stop to the bridge. for (const [ordinal, marker, interrupt] of [ diff --git a/packages/provider-acp-registry/src/server/adapter.ts b/packages/provider-acp-registry/src/server/adapter.ts index a1a5f77af8891..f021aa262b5ca 100644 --- a/packages/provider-acp-registry/src/server/adapter.ts +++ b/packages/provider-acp-registry/src/server/adapter.ts @@ -301,7 +301,9 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu // A steer restarts the host's turn; the sandbox's run, and its tasks, keep going. steerCancelMeta: { [SANDBOX_BRIDGE_CANCEL_META]: SANDBOX_BRIDGE_STEER_CANCEL }, // The bridge lists the sandbox's models only while the sandbox answers, and reads the - // pick from the thread when it sends, so a model it does not list now is no failure. + // pick from the thread when it sends, so a model it does not list now is no failure, + // and a session opened before it answered still takes a model change. + modelOptionArrivesLate: true, applyModelSelection: ({ runtime, modelSelection }) => runtime.setConfigOption("model", modelSelection.model).pipe( Effect.as(modelSelection.model), From 8f8769d6b6428316293ab2d054453a07a6ef7168 Mon Sep 17 00:00:00 2001 From: OmarAlex24 Date: Sun, 15 Nov 2026 19:26:31 +0000 Subject: [PATCH 4/4] fix(web): a sandbox chat's model pick that misses its other thread is reported Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/chat/useApplyThreadModelPick.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/web/src/components/chat/useApplyThreadModelPick.ts b/apps/web/src/components/chat/useApplyThreadModelPick.ts index 556d276a61acf..3d36fe65c0c8e 100644 --- a/apps/web/src/components/chat/useApplyThreadModelPick.ts +++ b/apps/web/src/components/chat/useApplyThreadModelPick.ts @@ -65,6 +65,12 @@ export function useApplyThreadModelPick() { input: { threadId: partner.ref.threadId, modelSelection: partner.modelSelection }, }), ]); + if (partnerResult?._tag === "Failure" && !isAtomCommandInterrupted(partnerResult)) { + console.warn( + "The sandbox chat's other thread did not take the model pick; the next message carries it.", + squashAtomCommandFailure(partnerResult), + ); + } // Until both threads have it, the composer keeps showing the pick and sends it. if (result._tag === "Success" && partnerResult?._tag !== "Failure") { useComposerDraftStore.getState().releaseModelSelection(target, pick);