Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/chat/useApplyThreadModelPick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
55 changes: 45 additions & 10 deletions packages/client-runtime/src/state/sandboxMirror.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
[],
);
});
});

Expand Down
45 changes: 41 additions & 4 deletions packages/client-runtime/src/state/sandboxMirror.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<Record<string, string>> = { 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<typeof sandboxMirrorModelSource>;
Expand All @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion packages/provider-acp-registry/src/server/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
44 changes: 44 additions & 0 deletions packages/sandbox-bridge/src/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
52 changes: 39 additions & 13 deletions packages/sandbox-bridge/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,22 +307,32 @@ export const withConfigValue = (
: option,
);

const optionKey = (selection: ModelSelection) =>
type OptionDescriptors = NonNullable<
NonNullable<ServerProvider["models"][number]["capabilities"]>["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,
Expand All @@ -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. */
Expand Down