Skip to content
15 changes: 15 additions & 0 deletions apps/mobile/src/state/thread-task-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,21 @@ describe("mobile task permissions", () => {
});
});

describe("queued follow-up model", () => {
it("queues an unpicked follow-up on the thread's current model", async () => {
state.grantedEnvironments.add("secondary");
const composer = useThreadComposerState(NO_SIDE_CHATS);

expect(await composer.onSendMessage()).toBe("message");

// A same-instance pick leaves the draft once the thread accepts it; without a model the
// server would run this message on the model queued ahead of it instead.
expect(state.enqueue).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ modelSelection: { instanceId: "codex", model: "gpt-5.4" } }),
);
});
});

describe("mobile handoff preview and submission", () => {
it("follows project enablement and provider availability for the selected destination", async () => {
state.grantedEnvironments.add("secondary");
Expand Down
38 changes: 37 additions & 1 deletion apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,40 @@ afterEach(() => {
state.cleanups.splice(0).forEach((cleanup) => cleanup());
});

describe("queued follow-up model", () => {
it("delivers a follow-up queued without a pick with no model, so the server keeps the thread's own", async () => {
setOperateGrant("secondary", true);
await state.manager.enqueue(message());

await runDrain();

expect(state.start).toHaveBeenCalledOnce();
expect(state.start.mock.calls[0]?.[0].input).not.toHaveProperty("modelSelection");
});

it("delivers a follow-up without a pick even when this client's copy of the thread model looks unavailable", async () => {
setOperateGrant("secondary", true);
state.modelUnavailable.mockReturnValue(true);
await state.manager.enqueue(message());

await runDrain();

expect(state.start).toHaveBeenCalledOnce();
expect(remaining()).toEqual([]);
});

it("delivers the model the user picked with the follow-up", async () => {
setOperateGrant("secondary", true);
const picked = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" };
await state.manager.enqueue(message({ modelSelection: picked }));

await runDrain();

expect(state.start).toHaveBeenCalledOnce();
expect(state.start.mock.calls[0]?.[0].input.modelSelection).toEqual(picked);
});
});

describe("queued task operation access", () => {
it("parks a connected read-only target, then delivers its unchanged queue after a grant", async () => {
const queued = message();
Expand All @@ -343,7 +377,9 @@ describe("queued task operation access", () => {

it("backs off instead of redelivering at once when a delivery hits a defect", async () => {
setOperateGrant("secondary", true);
const queued = message();
const queued = message({
modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" },
});
await state.manager.enqueue(queued);
// Awaited steps catch their own failures, so a defect in the delivery's own code is what
// rejects a delivery.
Expand Down
15 changes: 12 additions & 3 deletions apps/mobile/src/state/threadOutboxDelivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -792,7 +792,13 @@ export function createThreadOutboxDelivery(): {
);
if (!serverConfig) return false;
const settings = resolveQueuedThreadSettings(queuedMessage, thread, serverConfig.providers);
if (isModelSelectionUnavailable(serverConfig, settings.modelSelection, thread.projectId)) {
// Without a pick the server delivers on the thread's own model, which this client's copy
// of the thread can misreport right after reconnecting; the server checks that one itself.
const picked = queuedMessage.modelSelection !== undefined;
if (
picked &&
isModelSelectionUnavailable(serverConfig, settings.modelSelection, thread.projectId)
) {
const { title, detail } = describeUnavailableModelSelection(
serverConfig,
settings.modelSelection,
Expand Down Expand Up @@ -877,7 +883,10 @@ export function createThreadOutboxDelivery(): {
serverEnvironment.configValueAtom(queuedMessage.environmentId),
);
if (!currentConfig) return false;
if (isModelSelectionUnavailable(currentConfig, settings.modelSelection, thread.projectId)) {
if (
picked &&
isModelSelectionUnavailable(currentConfig, settings.modelSelection, thread.projectId)
) {
const { title, detail } = describeUnavailableModelSelection(
currentConfig,
settings.modelSelection,
Expand Down Expand Up @@ -942,7 +951,7 @@ export function createThreadOutboxDelivery(): {
),
attachments: prepared.attachments,
},
modelSelection: sendSettings.modelSelection,
...(picked ? { modelSelection: sendSettings.modelSelection } : {}),
titleSeed: deriveThreadTitleSeed({
text: queuedMessage.text,
attachments: queuedMessage.attachments,
Expand Down
Loading