Skip to content
Draft
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
133 changes: 133 additions & 0 deletions apps/mobile/src/state/use-composer-drafts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
v2ShellSnapshot,
v2ThreadShell,
} from "../../../../packages/client-runtime/src/state/orchestrationV2TestFixtures";
import * as DateTime from "effect/DateTime";
import * as Option from "effect/Option";
import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
import { afterEach, describe, expect, it } from "@effect/vitest";
Expand All @@ -20,6 +21,7 @@ import {
MessageId,
ProjectId,
ProviderInstanceId,
RunId,
ThreadId,
type OrchestrationV2ThreadShell,
type OrchestrationV2ShellSnapshot,
Expand Down Expand Up @@ -206,6 +208,7 @@ import {
setComposerDraftText,
insertComposerDraftContext,
insertComposerDraftText,
markComposerDraftModelSelectionDelivered,
rememberComposerDraftSelection,
setComposerDraftAttachmentUpload,
waitForComposerDraftsLoaded,
Expand Down Expand Up @@ -348,6 +351,7 @@ function modelReconciliationHarness(
expect(registry.get(selectedShell)?.id).toBe(selectedThreadId);
},
model: () => registry.get(shells.threadShellAtom({ environmentId, threadId }))!.modelSelection,
sequence: () => Option.getOrThrow(registry.get(shellStateAtom).snapshot).snapshotSequence,
};
}

Expand Down Expand Up @@ -496,6 +500,135 @@ describe("mobile composer drafts", () => {
expect(appAtomRegistry.get(composerDraftsAtom)[key]).toBeUndefined();
});

it("releases a delivered pick once no run waits to start, even if the thread already moved elsewhere", () => {
const environmentId = EnvironmentId.make("environment-1");
const threadId = ThreadId.make("thread-1");
const key = `${environmentId}:${threadId}`;
const pick = { instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-5-5" };
const elsewhere = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-6.1-sol" };
const startedAt = DateTime.makeUnsafe("2026-10-23T23:40:00.000Z");
const h = modelReconciliationHarness(environmentId, threadId);
updateComposerDraftSettings(key, { modelSelection: pick });
// Before the shell includes the delivery, nothing it shows is about the picked run.
h.update({ latestRunId: RunId.make("run-active"), status: "running", hasQueuedRuns: false });
markComposerDraftModelSelectionDelivered(key, pick, h.sequence() + 1);
h.flush();
expect(getComposerDraftSnapshot(key).modelSelection).toEqual(pick);

// Queued behind it: the server switches the thread only when the picked run starts.
h.update({ latestRunId: RunId.make("run-picked"), status: "queued", hasQueuedRuns: true });
expect(getComposerDraftSnapshot(key).modelSelection).toEqual(pick);

// The phone missed the thread running on the pick; another client moved it since.
h.update({
modelSelection: elsewhere,
latestRunId: RunId.make("run-later"),
status: "running",
latestRunStartedAt: startedAt,
hasQueuedRuns: false,
});
expect(getComposerDraftSnapshot(key).modelSelection).toBeUndefined();
});

it("releases a pick whose retried delivery returns a receipt the shell already passed", () => {
const environmentId = EnvironmentId.make("environment-1");
const threadId = ThreadId.make("thread-1");
const key = `${environmentId}:${threadId}`;
const pick = { instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-5-5" };
const elsewhere = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-6.1-sol" };
const h = modelReconciliationHarness(environmentId, threadId);
updateComposerDraftSettings(key, { modelSelection: pick });
// The first delivery's reply was lost; the picked run ran and the thread moved on.
const firstDelivery = h.sequence() + 1;
h.update({ latestRunId: RunId.make("run-picked"), status: "running", hasQueuedRuns: false });
h.update({ modelSelection: elsewhere, latestRunId: RunId.make("run-later") });
expect(getComposerDraftSnapshot(key).modelSelection).toEqual(pick);

markComposerDraftModelSelectionDelivered(key, pick, firstDelivery);
h.flush();

expect(getComposerDraftSnapshot(key).modelSelection).toBeUndefined();
});

it.each([
["held behind a failed run", { latestRunId: "run-failed", status: "failed" }],
[
"held behind a run being stopped",
{ latestRunId: "run-stopping", latestRunStopRequested: true },
],
["passed by a later run started ahead of it", { latestRunId: "run-reordered" }],
] as const)("keeps a delivered pick while its run still waits, %s", (_case, latest) => {
const environmentId = EnvironmentId.make("environment-1");
const threadId = ThreadId.make("thread-1");
const key = `${environmentId}:${threadId}`;
const pick = { instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-5-5" };
const h = modelReconciliationHarness(environmentId, threadId);
updateComposerDraftSettings(key, { modelSelection: pick });
markComposerDraftModelSelectionDelivered(key, pick, h.sequence() + 1);

h.update({
status: "running",
latestRunStartedAt: DateTime.makeUnsafe("2026-10-23T23:40:00.000Z"),
...latest,
latestRunId: RunId.make(latest.latestRunId),
hasQueuedRuns: true,
});

expect(getComposerDraftSnapshot(key).modelSelection).toEqual(pick);
});

it("keeps a delivered pick when the server does not report queued runs", () => {
const environmentId = EnvironmentId.make("environment-1");
const threadId = ThreadId.make("thread-1");
const key = `${environmentId}:${threadId}`;
const pick = { instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-5-5" };
const h = modelReconciliationHarness(environmentId, threadId);
updateComposerDraftSettings(key, { modelSelection: pick });
markComposerDraftModelSelectionDelivered(key, pick, h.sequence() + 1);

h.update({ latestRunId: RunId.make("run-later"), status: "running" });

expect(getComposerDraftSnapshot(key).modelSelection).toEqual(pick);
});

it("keeps an undelivered pick while the thread runs on another model", () => {
const environmentId = EnvironmentId.make("environment-1");
const threadId = ThreadId.make("thread-1");
const key = `${environmentId}:${threadId}`;
const pick = { instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-5-5" };
const h = modelReconciliationHarness(environmentId, threadId);
updateComposerDraftSettings(key, { modelSelection: pick });
h.flush();

h.update({
latestRunId: RunId.make("run-later"),
status: "running",
latestRunStartedAt: DateTime.makeUnsafe("2026-10-23T23:40:00.000Z"),
hasQueuedRuns: false,
});
expect(getComposerDraftSnapshot(key).modelSelection).toEqual(pick);
});

it("does not let an earlier delivery release a fresh pick of the same model", () => {
const environmentId = EnvironmentId.make("environment-1");
const threadId = ThreadId.make("thread-1");
const key = `${environmentId}:${threadId}`;
const pick = { instanceId: ProviderInstanceId.make("claudeAgent"), model: "claude-opus-5-5" };
const h = modelReconciliationHarness(environmentId, threadId);
updateComposerDraftSettings(key, { modelSelection: pick });
markComposerDraftModelSelectionDelivered(key, pick, h.sequence());
updateComposerDraftSettings(key, { modelSelection: pick });
h.flush();

h.update({
latestRunId: RunId.make("run-later"),
status: "running",
latestRunStartedAt: DateTime.makeUnsafe("2026-10-23T23:40:00.000Z"),
hasQueuedRuns: false,
});
expect(getComposerDraftSnapshot(key).modelSelection).toEqual(pick);
});

it("releases an inactive thread pick only after its queued model becomes durable", () => {
const environmentId = EnvironmentId.make("environment-1");
const threadId = ThreadId.make("thread-1");
Expand Down
Loading