Skip to content
Closed
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
11 changes: 9 additions & 2 deletions apps/mobile/src/state/thread-outbox-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ export const QueuedThreadMessageSchema = Schema.Struct({
context: Schema.optional(OrchestrationMessageContext),
attachments: Schema.Array(DraftComposerAttachmentSchema),
modelSelection: Schema.optional(ModelSelection),
// Written with every stored model since follow-ups carry only the user's pick. Entries without it
// come from builds that stored this client's cached thread model on every follow-up.
modelPicked: Schema.optional(Schema.Literal(true)),
dispatchMode: Schema.optional(Schema.Literals(["auto", "queue", "steer", "restart"])),
runtimeMode: Schema.optional(RuntimeMode),
interactionMode: Schema.optional(ProviderInteractionMode),
Expand Down Expand Up @@ -131,12 +134,16 @@ export function encodeQueuedThreadMessage(message: QueuedThreadMessage): unknown
return encodeStoredQueuedThreadMessage({
schemaVersion: THREAD_OUTBOX_SCHEMA_VERSION,
...message,
...(message.modelSelection === undefined ? {} : { modelPicked: true as const }),
});
}

export function decodeQueuedThreadMessage(value: unknown): QueuedThreadMessage {
const { schemaVersion: _, ...message } = decodeStoredQueuedThreadMessage(value);
return message;
const { schemaVersion: _, modelPicked, ...message } = decodeStoredQueuedThreadMessage(value);
if (message.creation !== undefined || modelPicked === true) return message;
// Possibly a stale cached model rather than a pick: let the server use the thread's own.
const { modelSelection: _unconfirmed, ...followUp } = message;
return followUp;
}

export function groupQueuedThreadMessages(
Expand Down
31 changes: 31 additions & 0 deletions apps/mobile/src/state/thread-outbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,37 @@ describe("thread outbox", () => {
decodeQueuedThreadMessage(JSON.parse(JSON.stringify(encodeQueuedThreadMessage(message)))),
).toEqual(message);
});
it("keeps a picked follow-up model through a persisted round trip", () => {
const message: QueuedThreadMessage = {
...queuedMessage({ messageId: "picked-model", createdAt: "2026-10-23T23:00:00.000Z" }),
modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" },
};

expect(
decodeQueuedThreadMessage(JSON.parse(JSON.stringify(encodeQueuedThreadMessage(message)))),
).toEqual(message);
});

it("drops the model an older build stored on a follow-up, so the server keeps the thread's own", () => {
const stale = { instanceId: "claudeAgent", model: "claude-opus-5-5" };
const base = queuedMessage({ messageId: "older-entry", createdAt: "2026-10-23T23:00:00.000Z" });
const stored = { schemaVersion: 3, ...base, modelSelection: stale };

expect(decodeQueuedThreadMessage(stored)).toEqual(base);
// A new thread needs its model, so an older creation entry keeps it.
const creation = {
projectId: ProjectId.make("project-1"),
workspaceMode: "local" as const,
branch: null,
worktreePath: null,
};
expect(decodeQueuedThreadMessage({ ...stored, creation })).toEqual({
...base,
modelSelection: stale,
creation,
});
});

it.each(["read", "json", "schema"] as const)(
"recovers usable messages without permitting cleanup after a record %s failure",
async (failure) => {
Expand Down
14 changes: 14 additions & 0 deletions apps/mobile/src/state/thread-task-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,20 @@ describe("mobile task permissions", () => {
});
});

describe("queued follow-up model", () => {
it("queues a follow-up with no model when the user picked none", async () => {
state.grantedEnvironments.add("secondary");
const composer = useThreadComposerState();

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

// The outbox can deliver after the thread moved elsewhere; only the server knows its model then.
expect(state.enqueue).toHaveBeenCalledOnce();
const [queued] = state.enqueue.mock.calls[0] as unknown as [object];
expect(queued).not.toHaveProperty("modelSelection");
});
});

describe("mobile handoff preview and submission", () => {
it("follows project enablement and provider availability for the selected destination", async () => {
state.grantedEnvironments.add("secondary");
Expand Down
4 changes: 3 additions & 1 deletion apps/mobile/src/state/use-thread-composer-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -707,7 +707,9 @@ export function useThreadComposerState() {
text,
attachments,
context: draft.context,
modelSelection,
// Only a pick travels with the message. The thread's model is read by the server when the
// outbox delivers, because this client's copy of it can be stale by then.
...(draft.modelSelection === undefined ? {} : { modelSelection: draft.modelSelection }),
runtimeMode: draft.runtimeMode ?? thread.runtimeMode,
interactionMode: resolveProviderInteractionMode(
provider,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const state = vi.hoisted(() => ({
interaction: vi.fn(),
prepare: vi.fn(),
checkout: vi.fn(),
cachedModelUnavailable: false,
manager: null as unknown as ReturnType<
typeof import("./thread-outbox-manager").createThreadOutboxManager
>,
Expand Down Expand Up @@ -82,7 +83,10 @@ vi.mock("./threads", async () => {
});
vi.mock("./use-atom-command", () => ({ useAtomCommand: <A>(command: A) => command }));
vi.mock("./git", () => ({ gitEnvironment: { preparePullRequestThread: state.checkout } }));
vi.mock("../lib/modelOptions", () => ({ isModelSelectionUnavailable: () => false }));
vi.mock("../lib/modelOptions", () => ({
isModelSelectionUnavailable: () => state.cachedModelUnavailable,
describeUnavailableModelSelection: () => ({ title: "Unavailable", detail: "Pick another model" }),
}));
vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid", randomHex: () => "abcd" }));
vi.mock("../lib/attachmentUpload", () => ({ prepareTurnAttachments: state.prepare }));
vi.mock("./use-remote-environment-registry", () => ({
Expand Down Expand Up @@ -173,6 +177,7 @@ function runDrain() {
beforeEach(() => {
state.grantedEnvironments = new Set(["primary"]);
state.draftText = "Keep this draft separate";
state.cachedModelUnavailable = false;
appAtomRegistry.set(state.manager.queuedMessagesByThreadKeyAtom, {});
appAtomRegistry.set(dispatchingQueuedMessageIdAtom, null);
for (const command of [
Expand Down Expand Up @@ -284,6 +289,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 () => {
state.grantedEnvironments.add("secondary");
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 () => {
state.grantedEnvironments.add("secondary");
state.cachedModelUnavailable = 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 () => {
state.grantedEnvironments.add("secondary");
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 Down
15 changes: 12 additions & 3 deletions apps/mobile/src/state/use-thread-outbox-drain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -743,7 +743,13 @@ export function useThreadOutboxDrain(): void {
);
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 @@ -827,7 +833,10 @@ export function useThreadOutboxDrain(): void {
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 @@ -861,7 +870,7 @@ export function useThreadOutboxDrain(): void {
),
attachments: prepared.attachments,
},
modelSelection: sendSettings.modelSelection,
...(picked ? { modelSelection: sendSettings.modelSelection } : {}),
titleSeed: deriveThreadTitleSeed({
text: queuedMessage.text,
attachments: queuedMessage.attachments,
Expand Down
Loading