diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 9fece2315820f..bf44d8af3e673 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -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), @@ -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( diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index 0d08c2af1073b..1d8525e7d656e 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -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) => { diff --git a/apps/mobile/src/state/thread-task-permissions.test.ts b/apps/mobile/src/state/thread-task-permissions.test.ts index 4c962cf5be650..f9b3274679e1e 100644 --- a/apps/mobile/src/state/thread-task-permissions.test.ts +++ b/apps/mobile/src/state/thread-task-permissions.test.ts @@ -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"); diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 94dc617a50d49..789ba98d2d4c3 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -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"; @@ -20,6 +21,7 @@ import { MessageId, ProjectId, ProviderInstanceId, + RunId, ThreadId, type OrchestrationV2ThreadShell, type OrchestrationV2ShellSnapshot, @@ -206,6 +208,7 @@ import { setComposerDraftText, insertComposerDraftContext, insertComposerDraftText, + markComposerDraftModelSelectionDelivered, rememberComposerDraftSelection, setComposerDraftAttachmentUpload, waitForComposerDraftsLoaded, @@ -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, }; } @@ -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"); diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 2549cb276bc0f..9299094c8e55c 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -14,6 +14,7 @@ import { RuntimeMode as RuntimeModeSchema, type EnvironmentId, type ModelSelection, + type OrchestrationV2ThreadShell, type ProjectId, type ProviderInteractionMode, type ProviderOptionSelection, @@ -64,6 +65,7 @@ import { encodeQueuedThreadMessage, QueuedThreadMessageSchema, type QueuedThreadMessage, + modelSelectionsEqual, } from "./thread-outbox-model"; import { flushThreadOutbox, threadOutboxManager } from "./thread-outbox"; import { composerDraftEnvironmentId } from "../lib/composerAttachmentUploadQueue"; @@ -337,12 +339,24 @@ export interface ComposerDraft { readonly attachments: ReadonlyArray; readonly importedShareIds?: ReadonlyArray; readonly modelSelection?: ModelSelection; + /** + * Set once a message carrying `modelSelection` was accepted, with the event sequence the server + * returned for it. Once the shell has passed that sequence and its latest run has started, the + * server applied the pick (or a later choice), so the pick is released even when this client + * never saw the thread on it. + */ + readonly modelSelectionDelivery?: ComposerDraftModelSelectionDelivery; readonly runtimeMode?: RuntimeMode; readonly interactionMode?: ProviderInteractionMode; readonly workspaceSelection?: ComposerDraftWorkspaceSelection; readonly project?: ComposerDraftProject; } +export interface ComposerDraftModelSelectionDelivery { + readonly modelSelection: ModelSelection; + readonly sequence: number; +} + export interface ComposerDraftProject { readonly environmentId: EnvironmentId; readonly projectId: ProjectId; @@ -396,6 +410,12 @@ const ComposerDraftSchema = Schema.Struct({ attachments: Schema.Array(DraftComposerAttachmentSchema), importedShareIds: Schema.optional(Schema.Array(Schema.String)), modelSelection: Schema.optional(ModelSelectionSchema), + modelSelectionDelivery: Schema.optional( + Schema.Struct({ + modelSelection: ModelSelectionSchema, + sequence: Schema.Number, + }), + ), runtimeMode: Schema.optional(RuntimeModeSchema), interactionMode: Schema.optional(ProviderInteractionModeSchema), workspaceSelection: Schema.optional(ComposerDraftWorkspaceSelectionSchema), @@ -1460,14 +1480,59 @@ export function updateComposerDraftSettings( settings: Partial, ): void { updateComposerDrafts((current) => { + const { modelSelectionDelivery, ...existing } = normalizeDraft(current[draftKey]); const draft = { - ...normalizeDraft(current[draftKey]), + ...existing, + ...(settings.modelSelection === undefined && modelSelectionDelivery !== undefined + ? { modelSelectionDelivery } + : {}), ...settings, }; return withComposerDraft(current, draftKey, draft); }); } +export function markComposerDraftModelSelectionDelivered( + draftKey: string, + delivered: ModelSelection, + sequence: number, +): void { + updateComposerDrafts((current) => { + const existing = current[draftKey]; + if (!existing?.modelSelection || !modelSelectionsEqual(existing.modelSelection, delivered)) { + return current; + } + return withComposerDraft(current, draftKey, { + ...existing, + modelSelectionDelivery: { modelSelection: delivered, sequence }, + }); + }); +} + +function isCurrentDelivery(draft: ComposerDraft): boolean { + const delivery = draft.modelSelectionDelivery; + return ( + delivery !== undefined && + draft.modelSelection !== undefined && + modelSelectionsEqual(delivery.modelSelection, draft.modelSelection) + ); +} + +// Once the shell includes the delivery and no run waits to start, the picked run has started (the +// server applied the pick, then maybe a later choice) or was removed. A server that does not report +// queued runs never releases a delivered pick this way. +function deliveredPickWasApplied( + draft: ComposerDraft, + snapshotSequence: number, + thread: Pick, +): boolean { + return ( + isCurrentDelivery(draft) && + snapshotSequence >= draft.modelSelectionDelivery!.sequence && + thread.hasQueuedRuns === false + ); +} + export function subscribeComposerDraftModelReconciliation(input: { readonly registry: AtomRegistry.AtomRegistry; readonly threadRefsAtom: Atom.Atom>; @@ -1492,19 +1557,26 @@ export function subscribeComposerDraftModelReconciliation(input: { const ref = parseScopedThreadKey(key); if (!ref || !threadKeys.has(key)) continue; const thread = get(input.threadShellAtom(ref)); - if (!thread || !draftModelPickMatchesThread(draft.modelSelection, thread.modelSelection)) + if ( + !thread || + (!draftModelPickMatchesThread(draft.modelSelection, thread.modelSelection) && + !isCurrentDelivery(draft)) + ) continue; // Status and selection must come from the same emission; derived atoms can update apart. const shell = Option.getOrNull( AsyncResult.value(get(input.shellStateAtom(ref.environmentId))), ); if (shell?.status !== "live") continue; - const authoritativeThread = Option.getOrNull(shell.snapshot)?.threads.find( + const snapshot = Option.getOrNull(shell.snapshot); + const authoritativeShell = snapshot?.threads.find( (candidate) => candidate.id === ref.threadId, ); if ( - authoritativeThread && - draftModelPickMatchesThread(draft.modelSelection, authoritativeThread.modelSelection) + snapshot && + authoritativeShell && + (draftModelPickMatchesThread(draft.modelSelection, authoritativeShell.modelSelection) || + deliveredPickWasApplied(draft, snapshot.snapshotSequence, authoritativeShell)) ) { matches.push([key, draft]); } @@ -1519,7 +1591,11 @@ export function subscribeComposerDraftModelReconciliation(input: { let next = current; for (const [key, matchedDraft] of matches) { if (next[key] !== matchedDraft) continue; - const { modelSelection: _modelSelection, ...draft } = matchedDraft; + const { + modelSelection: _modelSelection, + modelSelectionDelivery: _modelSelectionDelivery, + ...draft + } = matchedDraft; next = withComposerDraft(next, key, draft); } return next; @@ -1549,6 +1625,7 @@ export function clearComposerDraftContentState( importedShareIds: _importedShareIds, context: _context, modelSelection, + modelSelectionDelivery, workspaceSelection, project: _project, ...retained @@ -1556,6 +1633,9 @@ export function clearComposerDraftContentState( const draft = { ...retained, ...(options?.clearModelSelection || modelSelection === undefined ? {} : { modelSelection }), + ...(options?.clearModelSelection || modelSelectionDelivery === undefined + ? {} + : { modelSelectionDelivery }), ...(options?.clearWorkspaceSelection || workspaceSelection === undefined ? {} : { workspaceSelection }), diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index ec71677013b44..494dbdc343789 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -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, diff --git a/apps/mobile/src/state/use-thread-outbox-drain.permissions.test.ts b/apps/mobile/src/state/use-thread-outbox-drain.permissions.test.ts index 3bdab5f7a6223..7c607eeca2670 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.permissions.test.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.permissions.test.ts @@ -22,6 +22,8 @@ const state = vi.hoisted(() => ({ interaction: vi.fn(), prepare: vi.fn(), checkout: vi.fn(), + markDelivered: vi.fn(), + cachedModelUnavailable: false, manager: null as unknown as ReturnType< typeof import("./thread-outbox-manager").createThreadOutboxManager >, @@ -82,7 +84,10 @@ vi.mock("./threads", async () => { }); vi.mock("./use-atom-command", () => ({ useAtomCommand: (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", () => ({ @@ -134,6 +139,7 @@ vi.mock("./use-composer-drafts", async () => { appendComposerDraftAttachments: vi.fn(), flushComposerDrafts: vi.fn(), getComposerDraftSnapshot: vi.fn(() => ({ text: state.draftText, attachments: [] })), + markComposerDraftModelSelectionDelivered: state.markDelivered, mergeComposerDraftContent: vi.fn(async (_key: string, input: { text: string }) => { state.draftText += `\n${input.text}`; }), @@ -173,6 +179,8 @@ function runDrain() { beforeEach(() => { state.grantedEnvironments = new Set(["primary"]); state.draftText = "Keep this draft separate"; + state.cachedModelUnavailable = false; + state.markDelivered.mockReset(); appAtomRegistry.set(state.manager.queuedMessagesByThreadKeyAtom, {}); appAtomRegistry.set(dispatchingQueuedMessageIdAtom, null); for (const command of [ @@ -284,6 +292,43 @@ 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 })); + state.start.mockResolvedValueOnce(AsyncResult.success({ sequence: 42 })); + + await runDrain(); + + expect(state.start).toHaveBeenCalledOnce(); + expect(state.start.mock.calls[0]?.[0].input.modelSelection).toEqual(picked); + // The draft keeps the pick until a later run shows the server applied it. + expect(state.markDelivered).toHaveBeenCalledExactlyOnceWith("secondary:thread", picked, 42); + }); +}); + describe("queued task operation access", () => { it("parks a connected read-only target, then delivers its unchanged queue after a grant", async () => { const queued = message(); diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index a979632029461..99af0c0cc61d7 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -72,6 +72,7 @@ import { flushComposerDrafts, type ComposerDraft, getComposerDraftSnapshot, + markComposerDraftModelSelectionDelivered, mergeComposerDraftContent, replaceComposerDraftAttachments, removeDeliveredCloudQueuedMessage, @@ -742,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, @@ -826,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, @@ -860,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, @@ -880,6 +890,19 @@ export function useThreadOutboxDrain(): void { return restoreQueuedMessage(persistedMessage, failure.message); } acknowledgedExistingThreadMessageIdsRef.current.add(persistedMessage.messageId); + // A retried command returns its original receipt, so the sequence names this dispatch. + const delivery = AsyncResult.isSuccess(deliveryResult) ? deliveryResult.value : undefined; + const deliveredSequence = + typeof delivery === "object" && delivery !== null && "sequence" in delivery + ? delivery.sequence + : undefined; + if (picked && deliveredSequence !== undefined) { + markComposerDraftModelSelectionDelivered( + scopedThreadKey(queuedMessage.environmentId, queuedMessage.threadId), + sendSettings.modelSelection, + deliveredSequence, + ); + } const delivered = (await completeQueuedMessageDelivery(persistedMessage, deliveryRevision)) === "removed"; if (delivered) { diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 00333529dd1fb..d1a25299eb4b6 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1064,6 +1064,7 @@ type ShellThreadRow = { readonly latest_run_started_at: string | null; readonly latest_run_completed_at: string | null; readonly latest_run_stop_requested: number; + readonly has_queued_runs: number; readonly active_run_id: string | null; readonly active_run_status: string | null; readonly activity_run_status: string | null; @@ -1614,6 +1615,7 @@ export function threadShellFromProjection( projection.turnItems.some( (item) => item.runId === latestRun.id && item.type === "run_interrupt_request", ), + hasQueuedRuns: projection.runs.some((run) => run.status === "queued"), activeRunId: activeRun?.id ?? null, blockedOnThreadWait: deriveBlockedOnThreadWait({ activeRun, turnItems: projection.turnItems }), activityRunStatus: activityRun?.status ?? null, @@ -1714,6 +1716,7 @@ type ShellThreadState = { readonly latestRunStartedAt: DateTime.Utc | null; readonly latestRunCompletedAt: DateTime.Utc | null; readonly latestRunStopRequested: boolean; + readonly hasQueuedRuns: boolean; readonly activeRunId: RunId | null; readonly blockedOnThreadWait: boolean; readonly activityRunStatus: ShellActivityRunStatus | null; @@ -1869,6 +1872,7 @@ function shellFromState(input: { latestRunStartedAt: input.state.latestRunStartedAt, latestRunCompletedAt: input.state.latestRunCompletedAt, latestRunStopRequested: input.state.latestRunStopRequested, + hasQueuedRuns: input.state.hasQueuedRuns, activeRunId: input.state.activeRunId, blockedOnThreadWait: input.state.blockedOnThreadWait, activityRunStatus: input.state.activityRunStatus, @@ -5663,6 +5667,10 @@ export const layer: Layer.Layer = WHERE stop_request.thread_id = t.thread_id AND stop_request.run_id = presented.run_id AND stop_request.type = 'run_interrupt_request' ) AS latest_run_stop_requested, + EXISTS ( + SELECT 1 FROM orchestration_v2_projection_runs queued + WHERE queued.thread_id = t.thread_id AND queued.status = 'queued' + ) AS has_queued_runs, active.run_id AS active_run_id, active.status AS active_run_status, ( @@ -6250,6 +6258,7 @@ export const layer: Layer.Layer = latestRunStartedAt, latestRunCompletedAt, latestRunStopRequested, + hasQueuedRuns: row.has_queued_runs === 1, activeRunId: row.active_run_id === null ? null : RunId.make(row.active_run_id), blockedOnThreadWait: deriveBlockedOnThreadWait({ activeRun: diff --git a/apps/server/src/orchestration-v2/ThreadStop.test.ts b/apps/server/src/orchestration-v2/ThreadStop.test.ts index 5952e5c217a3e..ed3cc27bba508 100644 --- a/apps/server/src/orchestration-v2/ThreadStop.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStop.test.ts @@ -876,6 +876,50 @@ it.effect.each([false, true])( }).pipe(Effect.provide(layerTest)), ); +it.effect("the thread shell says whether a run still waits to start, held runs included", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make("thread:shell-queued-runs"); + const shells = Effect.gen(function* () { + const snapshot = (yield* projections.getShellSnapshot()).threads.find( + (thread) => thread.id === threadId, + ); + const memoryShell = ProjectionStore.threadShellFromProjection( + yield* orchestrator.getThreadProjection(threadId), + ); + return [yield* projections.getThreadShell(threadId), snapshot, memoryShell].map( + (shell) => shell?.hasQueuedRuns, + ); + }); + yield* createWatchingThread(threadId, 9); + yield* send(threadId, "active", "start_immediately"); + assert.deepEqual(yield* shells, [false, false, false]); + + yield* send(threadId, "queued", "queue_after_active"); + assert.deepEqual(yield* shells, [true, true, true]); + + yield* orchestrator.dispatch({ + type: "thread.stop", + commandId: CommandId.make("stop"), + threadId, + }); + const held = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.status === "queued", + )!; + assert.isTrue(held.queueHeld); + assert.deepEqual(yield* shells, [true, true, true]); + + yield* orchestrator.dispatch({ + type: "queued-run.cancel", + commandId: CommandId.make("cancel-queued"), + threadId, + runId: held.id, + }); + assert.deepEqual(yield* shells, [false, false, false]); + }).pipe(Effect.provide(layerTest)), +); + it.effect("a run Stop reached cannot delegate or start a watch, even after it ends", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 426818dba7a24..d77ce646d83e2 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -10,7 +10,7 @@ import { } from "./ChatView.logic"; import { recallCheckoutIsRepo, rememberCheckoutIsRepo } from "./ChatView.logic"; import { ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; -import { deriveProviderInstanceEntries } from "../providerInstances"; +import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances"; import { ensureInlineContextReferences } from "../lib/composerContextReferences"; import { terminalContextReference } from "../lib/composerContextRecords"; import { @@ -39,6 +39,7 @@ import type { Thread } from "../types"; import { makeThreadFixture, makeThreadProjectionFixture } from "../test-fixtures"; import { getAntigravitySendBlockReason, + getUnchosenProviderSwitchBlockReason, resolveComposerProviderSelection, resolveWorktreeSetupProgress, peekRememberedThreadTimeline, @@ -649,6 +650,92 @@ describe("resolveComposerProviderSelection", () => { expect(selection.unavailableProviderInstanceId).toBe(missingInstanceId); }); + describe("a started chat whose own account is turned off or missing", () => { + const work = entry("claudeAgent", "claude_work", { displayName: "Claude - work" }); + const other = entry("claudeAgent", "claude_other", { displayName: "Claude - other" }); + + function attempt(input: { + entries: ReadonlyArray; + threadStarted?: boolean; + chosenInstanceId?: ProviderInstanceId | null; + }) { + const threadInstanceId = work.instanceId; + const chosenInstanceId = input.chosenInstanceId ?? null; + // The composer passes no driver lock when the server can hand context between providers. + const selection = resolveComposerProviderSelection({ + entries: input.entries, + candidateInstanceIds: [chosenInstanceId, threadInstanceId, other.instanceId], + lockedProvider: null, + lockedInstanceId: threadInstanceId, + }); + return { + selectedInstanceId: selection.selectedProviderEntry?.instanceId, + blockReason: getUnchosenProviderSwitchBlockReason({ + threadStarted: input.threadStarted ?? true, + threadInstanceId, + chosenInstanceId, + selectedInstanceId: selection.selectedProviderEntry?.instanceId, + entries: input.entries, + }), + }; + } + + it("blocks the send instead of moving the chat to another account when its own is turned off", () => { + const result = attempt({ entries: [{ ...work, enabled: false }, other] }); + + expect(result.selectedInstanceId).toBe(other.instanceId); + expect(result.blockReason).toBe( + "Claude - work is turned off, so this chat can't continue on it. Turn it back on in provider settings, or pick a model to move this chat.", + ); + }); + + it("blocks the send when the chat's account is no longer set up", () => { + const result = attempt({ entries: [other] }); + + expect(result.selectedInstanceId).toBe(other.instanceId); + expect(result.blockReason).toBe( + "This chat's account (claude_work) is no longer set up. Add it back in provider settings, or pick a model to move this chat.", + ); + }); + + it("sends on another account the owner picked on purpose", () => { + const result = attempt({ + entries: [{ ...work, enabled: false }, other], + chosenInstanceId: other.instanceId, + }); + + expect(result.selectedInstanceId).toBe(other.instanceId); + expect(result.blockReason).toBeNull(); + }); + + it("keeps sending on the chat's own account while it is on", () => { + const result = attempt({ entries: [work, other] }); + + expect(result.selectedInstanceId).toBe(work.instanceId); + expect(result.blockReason).toBeNull(); + }); + + it("asks for a pick when the chat's account is back on but the composer still points elsewhere", () => { + const reason = getUnchosenProviderSwitchBlockReason({ + threadStarted: true, + threadInstanceId: work.instanceId, + chosenInstanceId: null, + selectedInstanceId: other.instanceId, + entries: [work, other], + }); + + expect(reason).toBe( + "Pick Claude - work in the model menu to keep this chat on it, or pick another model to move this chat.", + ); + }); + + it("lets a chat that has not started yet use whichever account is available", () => { + const result = attempt({ entries: [other], threadStarted: false }); + + expect(result.blockReason).toBeNull(); + }); + }); + it("keeps the session's continuation group when another instance was selected", () => { const sessionEntry = entry("antigravity", "google_work", { enabled: false, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 0ce51ee5e5534..f1f57e97f0c3f 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -633,6 +633,35 @@ export function resolveComposerInteractionMode(input: { }; } +// The composer falls back to another account when a started chat's own one is turned off or +// missing. Sending that fallback would hand the chat to an account nobody picked. +export function getUnchosenProviderSwitchBlockReason(input: { + threadStarted: boolean; + threadInstanceId: ProviderInstanceId | null | undefined; + chosenInstanceId: ProviderInstanceId | null | undefined; + selectedInstanceId: ProviderInstanceId | null | undefined; + entries: ReadonlyArray< + Pick + >; +}): string | null { + const { threadInstanceId, selectedInstanceId } = input; + if (!input.threadStarted || threadInstanceId == null || selectedInstanceId == null) return null; + if (selectedInstanceId === threadInstanceId || selectedInstanceId === input.chosenInstanceId) { + return null; + } + const own = input.entries.find((entry) => entry.instanceId === threadInstanceId); + if (own === undefined) { + return `This chat's account (${threadInstanceId}) is no longer set up. Add it back in provider settings, or pick a model to move this chat.`; + } + if (!own.enabled) { + return `${own.displayName} is turned off, so this chat can't continue on it. Turn it back on in provider settings, or pick a model to move this chat.`; + } + // An option edit made while the chat's account was off still points the composer elsewhere. + return own.isAvailable + ? `Pick ${own.displayName} in the model menu to keep this chat on it, or pick another model to move this chat.` + : `${own.displayName} is unavailable, so this chat can't continue on it. Check it in provider settings, or pick a model to move this chat.`; +} + export function getAntigravitySendBlockReason( provider: | Pick diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index eaf958fa2f5cf..9b122de050989 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -76,8 +76,10 @@ import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; import { deriveComposerSendState, getAntigravitySendBlockReason, + getUnchosenProviderSwitchBlockReason, readFileAsDataUrl, resolveComposerInteractionMode, + threadHasStarted, threadShellHasStarted, } from "../ChatView.logic"; import { @@ -96,6 +98,7 @@ import { composerFileNeedsReattach, composerDraftHasUserContent, composerTargetKey, + pickedDraftProviderInstanceId, useComposerDraftStore, useComposerThreadDraft, } from "../../composerDraftStore"; @@ -987,10 +990,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? runtimeMode : (compatibleRuntimeModeOptions[0]?.mode ?? runtimeMode); - const providerSendBlockReason = getAntigravitySendBlockReason( - selectedProviderEntry?.snapshot, - selectedModel, - ); + const providerSendBlockReason = + getUnchosenProviderSwitchBlockReason({ + threadStarted: threadHasStarted(activeThread), + threadInstanceId: + activeThread?.runtime?.providerInstanceId ?? activeThreadModelSelection?.instanceId, + chosenInstanceId: pickedDraftProviderInstanceId(composerDraft), + selectedInstanceId: selectedProviderEntry?.instanceId, + entries: providerInstanceEntries, + }) ?? getAntigravitySendBlockReason(selectedProviderEntry?.snapshot, selectedModel); const sendDisabledReason = externalSendDisabledReason ?? (multipleModelSelections?.length === 0 ? "Select at least one model." : null) ?? diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 44f1d5a92a911..cec58fc3d6b64 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -85,9 +85,11 @@ import { type ComposerImageAttachment, composerFileNeedsReattach, partializeComposerDraftStoreState, + pickedDraftProviderInstanceId, useComposerDraftStore, DraftId, } from "./composerDraftStore"; +import { getUnchosenProviderSwitchBlockReason } from "./components/ChatView.logic"; import { insertInlineContextReference } from "./lib/composerContextReferences"; import { terminalContextReference, threadContextRecord } from "./lib/composerContextRecords"; import { @@ -1320,6 +1322,7 @@ describe("composerDraftStore modelSelection", () => { activeProvider: null, modelSelectionByProvider: {}, modelSelectionExplicit: undefined, + providerPicked: undefined, }); expect(draftFor(threadId, OTHER_TEST_ENVIRONMENT_ID)).toEqual(remoteBefore); @@ -1535,6 +1538,96 @@ describe("composerDraftStore modelSelection", () => { }), ); }); + + it("keeps blocking a silent account move after an effort change on the fallback account", () => { + const store = useComposerDraftStore.getState(); + // The chat runs on the secondary account, which is turned off; the composer shows the primary. + const entries = [ + { + instanceId: CODEX_SECONDARY_INSTANCE, + displayName: "Codex - work", + enabled: false, + isAvailable: true, + }, + { instanceId: CODEX_INSTANCE, displayName: "Codex", enabled: true, isAvailable: true }, + ]; + const blockReason = () => + getUnchosenProviderSwitchBlockReason({ + threadStarted: true, + threadInstanceId: CODEX_SECONDARY_INSTANCE, + chosenInstanceId: pickedDraftProviderInstanceId(draftFor(threadId, TEST_ENVIRONMENT_ID)), + selectedInstanceId: CODEX_INSTANCE, + entries, + }); + + store.setProviderModelOptions( + threadRef, + CODEX_DRIVER, + toSelections({ reasoningEffort: "low" }), + { instanceId: CODEX_INSTANCE, model: "gpt-5.4" }, + ); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.activeProvider).toBe(CODEX_INSTANCE); + expect(blockReason()).toBe( + "Codex - work is turned off, so this chat can't continue on it. Turn it back on in provider settings, or pick a model to move this chat.", + ); + + store.setModelSelection(threadRef, createModelSelection(CODEX_INSTANCE, "gpt-5.4"), { + explicit: true, + }); + expect(blockReason()).toBeNull(); + }); + + it("counts a model-picker pick even when an effort change already stored that model", () => { + const store = useComposerDraftStore.getState(); + store.setProviderModelOptions( + threadRef, + CODEX_DRIVER, + toSelections({ reasoningEffort: "low" }), + { instanceId: CODEX_SECONDARY_INSTANCE, model: "gpt-5-codex" }, + ); + + store.setModelSelection( + threadRef, + createModelSelection( + CODEX_SECONDARY_INSTANCE, + "gpt-5-codex", + toSelections({ reasoningEffort: "low" }), + ), + { explicit: true, replaceOptions: true }, + ); + + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.providerPicked).toBe(true); + }); + + it("keeps a pick through effort changes on that account and drops it for another account", () => { + const store = useComposerDraftStore.getState(); + store.setModelSelection( + threadRef, + createModelSelection(CODEX_SECONDARY_INSTANCE, "gpt-5-codex"), + { + explicit: true, + replaceOptions: true, + }, + ); + + store.setProviderModelOptions( + threadRef, + CODEX_DRIVER, + toSelections({ reasoningEffort: "high" }), + { instanceId: CODEX_SECONDARY_INSTANCE, model: "gpt-5-codex" }, + ); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.providerPicked).toBe(true); + + store.setProviderModelOptions( + threadRef, + CODEX_DRIVER, + toSelections({ reasoningEffort: "high" }), + { instanceId: CODEX_INSTANCE, model: "gpt-5.4" }, + ); + const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); + expect(draft?.activeProvider).toBe(CODEX_INSTANCE); + expect(draft?.providerPicked).toBeUndefined(); + }); }); describe("composerDraftStore per-model sticky options", () => { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 57ffde65949ff..a5ff2891ae43a 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -371,6 +371,9 @@ export interface ComposerThreadDraftState { modelSelectionByProvider: Partial>; activeProvider: ProviderInstanceId | null; modelSelectionExplicit?: boolean; + // Set only when the user picks `activeProvider` in the model picker; option edits also set + // `activeProvider`, and must not count as choosing to move a started thread. Never persisted. + providerPicked?: true; runtimeMode: RuntimeMode | null; interactionMode: ProviderInteractionMode | null; } @@ -2848,11 +2851,16 @@ const composerDraftStore = create()( if ( Equal.equals(base.modelSelectionByProvider, nextMap) && base.activeProvider === stickyActiveProvider && - base.modelSelectionExplicit === undefined + base.modelSelectionExplicit === undefined && + base.providerPicked === undefined ) { return state; } - const { modelSelectionExplicit: _modelSelectionExplicit, ...retained } = base; + const { + modelSelectionExplicit: _modelSelectionExplicit, + providerPicked: _providerPicked, + ...retained + } = base; const nextDraft: ComposerThreadDraftState = { ...retained, modelSelectionByProvider: nextMap, @@ -2951,16 +2959,23 @@ const composerDraftStore = create()( if ( Equal.equals(base.modelSelectionByProvider, nextMap) && base.activeProvider === nextActiveProvider && - (base.modelSelectionExplicit ?? false) === (opts?.explicit === true) + (base.modelSelectionExplicit ?? false) === (opts?.explicit === true) && + (base.providerPicked ?? false) === (opts?.explicit === true) ) { return state; } - const { modelSelectionExplicit: _previousExplicit, ...restBase } = base; + const { + modelSelectionExplicit: _previousExplicit, + providerPicked: _previousPicked, + ...restBase + } = base; const nextDraft: ComposerThreadDraftState = { ...restBase, modelSelectionByProvider: nextMap, activeProvider: nextActiveProvider, - ...(opts?.explicit === true ? { modelSelectionExplicit: true as const } : {}), + ...(opts?.explicit === true + ? { modelSelectionExplicit: true as const, providerPicked: true as const } + : {}), }; const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; if (shouldRemoveDraft(nextDraft)) { @@ -3100,12 +3115,16 @@ const composerDraftStore = create()( return state; } - const { modelSelectionExplicit: _previousExplicit, ...restBase } = base; + const { modelSelectionExplicit: _previousExplicit, providerPicked, ...restBase } = base; + const nextActiveProvider = options?.instanceId ? instanceKey : base.activeProvider; const nextDraft: ComposerThreadDraftState = { ...restBase, - ...(options?.instanceId ? { activeProvider: instanceKey } : {}), + activeProvider: nextActiveProvider, modelSelectionByProvider: nextMap, modelSelectionExplicit: true, + ...(providerPicked && nextActiveProvider === base.activeProvider + ? { providerPicked } + : {}), }; const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; if (shouldRemoveDraft(nextDraft)) { @@ -4202,7 +4221,11 @@ export function subscribeComposerDraftModelReconciliation(input: { const drafts = { ...state.draftsByThreadKey }; for (const [key, matchedDraft] of matches) { if (drafts[key] !== matchedDraft) continue; - const { modelSelectionExplicit: _explicit, ...retained } = matchedDraft; + const { + modelSelectionExplicit: _explicit, + providerPicked: _picked, + ...retained + } = matchedDraft; const draft = { ...retained, activeProvider: null, modelSelectionByProvider: {} }; if (shouldRemoveDraft(draft)) delete drafts[key]; else drafts[key] = draft; @@ -4321,6 +4344,13 @@ export function useThreadHasUnsentDraft(threadRef: ScopedThreadRef): boolean { ); } +// The account the user picked in the model picker, or null when nothing was picked there. +export function pickedDraftProviderInstanceId( + draft: Pick | null | undefined, +): ProviderInstanceId | null { + return draft?.providerPicked ? draft.activeProvider : null; +} + export function useComposerDraftModelState( threadRef: ComposerThreadTarget, ): ComposerDraftModelState { diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index ff7c295b8bf91..74cae0c5bc29f 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1834,6 +1834,8 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ latestRunStartedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), latestRunCompletedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), latestRunStopRequested: Schema.optional(Schema.Boolean), + // Held runs count: a run still waiting to start has not applied its model to the thread. + hasQueuedRuns: Schema.optional(Schema.Boolean), activeRunId: Schema.NullOr(RunId), activityRunStartedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), activityRunStatus: Schema.optional(