1import {
2 AuthOrchestrationOperateScope,
3 DEFAULT_SERVER_SETTINGS,
4 MessageId,
5 ProjectId,
6 ProviderDriverKind,
7 ProviderInstanceId,
8 RunId,
9 RuntimeRequestId,
10 TurnItemId,
11 type ModelSelection,
12 type OrchestrationV2ThreadProjection,
13 type RuntimeMode,
14 type ServerConfig,
15} from "@t3tools/contracts";
16import {
17 v2Now,
18 v2Projection,
19} from "../../../../packages/client-runtime/src/state/orchestrationV2TestFixtures";
20import type { DraftComposerImageAttachment } from "../lib/composerImages";
21import type { PendingThreadRequests } from "@t3tools/client-runtime/state/thread-requests";
22import { sendApprovalFeedback } from "@t3tools/client-runtime/state/thread-requests";
23import { DateTime } from "effect";
24import { AsyncResult, type Atom } from "effect/reactivity";
25import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
26
27const state = vi.hoisted(() => ({
28 grantedEnvironments: new Set<string>(),
29 connectionState: "connected",
30 draft: {
31 text: "Keep my draft",
32 attachments: [] as DraftComposerImageAttachment[],
33 modelSelection: undefined as ModelSelection | undefined,
34 runtimeMode: undefined as RuntimeMode | undefined,
35 },
36 serverConfig: null as ServerConfig | null,
37 projection: null as OrchestrationV2ThreadProjection | null,
38 pendingRequests: { approvals: [], userInputs: [] } as PendingThreadRequests,
39 enqueue: vi.fn(async () => undefined),
40 freshPending: false,
41 compactModel: null as string | null,
42 compactDueAt: "",
43 setFreshPending: vi.fn(),
44 clearDraft: vi.fn(),
45 approve: vi.fn(),
46 answer: vi.fn(),
47 feedback: vi.fn(),
48 updateSettings: vi.fn(),
49 question: {
50 id: "language",
51 header: "Language",
52 question: "Which language?",
53 options: [{ label: "TypeScript", description: "Use the existing code" }],
54 multiSelect: false,
55 },
56 thread: {
57 id: "thread",
58 environmentId: "secondary",
59 lineage: { rootThreadId: "thread", parentThreadId: null, relationshipToParent: null },
60 projectId: "project",
61 modelSelection: { instanceId: "codex", model: "gpt-5.4" },
62 runtimeMode: "full-access",
63 interactionMode: "default",
64 runtime: null,
65 latestRun: null as unknown,
66 },
67}));
68
69vi.mock("react", () => ({
70 useCallback: <A>(callback: A) => callback,
71 useEffect: () => {},
72 useLayoutEffect: () => {},
73 useRef: <A>(current: A) => ({ current }),
74 useMemo: <A>(factory: () => A) => factory(),
75 useState: <A>(initial: A | (() => A)) => [
76 typeof initial === "function" ? (initial as () => A)() : initial,
77 vi.fn(),
78 ],
79}));
80vi.mock("expo-crypto", () => ({ randomUUID: () => "uuid" }));
81vi.mock("@t3tools/client-runtime/state/context-window", async (importOriginal) => ({
82 ...(await importOriginal<typeof import("@t3tools/client-runtime/state/context-window")>()),
83 compactBeforeSendTokens: (input: { readonly selection: ModelSelection; readonly now: string }) =>
84 input.selection.model === state.compactModel && input.now >= state.compactDueAt
85 ? 248_000
86 : null,
87}));
88vi.mock("../lib/clock", () => ({ useNowMinuteMs: () => 0 }));
89vi.mock("../lib/attachmentUpload", () => ({ prepareTurnAttachments: vi.fn() }));
90vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid" }));
91vi.mock("./queued-run-edit", () => ({
92 useQueuedRunEdit: () => null,
93 getQueuedRunEdit: () => null,
94}));
95vi.mock("./preferences", async () => {
96 const { Atom, AsyncResult } = await import("effect/reactivity");
97 return { mobilePreferencesAtom: Atom.make(AsyncResult.success({})) };
98});
99vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } }));
100vi.mock("@effect/atom-react", async () => {
101 const { appAtomRegistry } = await import("./atom-registry");
102 return { useAtomValue: <A>(atom: Atom.Atom<A>) => appAtomRegistry.get(atom) };
103});
104vi.mock("./entities", () => ({ useServerConfigs: () => new Map() }));
105vi.mock("./session", () => ({
106 readEnvironmentScope: (environmentId: string, scope: string) =>
107 scope === AuthOrchestrationOperateScope && state.grantedEnvironments.has(environmentId),
108}));
109vi.mock("./use-thread-selection", () => ({
110 useThreadSelection: () => ({
111 selectedThread: state.thread,
112 selectedThreadCreation: null,
113 selectedEnvironmentRuntime: {
114 connectionState: state.connectionState,
115 serverConfig: state.serverConfig,
116 },
117 }),
118}));
119vi.mock("./use-thread-detail", () => ({
120 useSelectedThreadProjection: () =>
121 state.projection === null ? null : { projection: state.projection },
122 useSelectedThreadVisibleTurnItems: () => [],
123 useSelectedThreadPendingRequests: () => state.pendingRequests,
124}));
125vi.mock("./use-atom-command", () => ({ useAtomCommand: <A>(command: A) => command }));
126vi.mock("./threads", async () => {
127 const { Atom } = await import("effect/reactivity");
128 return {
129 environmentThreadDetails: {
130 queueWorkflowAtom: () => Atom.make(null),
131 checkpointSummariesAtom: () => Atom.make([]),
132 sequenceAtom: () => Atom.make(0),
133 pendingRequestsAtom: () => Atom.make(state.pendingRequests),
134 },
135 threadEnvironment: {
136 respondToApproval: state.approve,
137 respondToUserInput: state.answer,
138 uploadFeedback: state.feedback,
139 },
140 };
141});
142vi.mock("./use-thread-outbox", async () => {
143 const { Atom } = await import("effect/reactivity");
144 return { dispatchingQueuedMessageIdAtom: Atom.make(null), useThreadOutboxMessages: () => ({}) };
145});
146vi.mock("./thread-outbox", () => ({ enqueueThreadOutboxMessage: state.enqueue }));
147vi.mock("./composer-attachment-uploads", async () => {
148 const { Atom } = await import("effect/reactivity");
149 return {
150 composerAttachmentUploadsAtom: Atom.make({}),
151 composerAttachmentUploadBlockReason: () => null,
152 composerAttachmentsStillUploading: () => false,
153 };
154});
155vi.mock("./use-composer-drafts", async () => {
156 const { Atom } = await import("effect/reactivity");
157 return {
158 composerDraftsAtom: Atom.make({}),
159 composerContextImportsAtom: Atom.make({}),
160 getComposerDraftSnapshot: () => state.draft,
161 isFreshSessionPending: () => state.freshPending,
162 setFreshSessionPending: state.setFreshPending,
163 clearComposerDraftContent: state.clearDraft,
164 clearComposerDraft: state.clearDraft,
165 setComposerDraftText: (_key: string, text: string) => {
166 state.draft.text = text;
167 },
168 updateComposerDraftSettings: state.updateSettings,
169 ensureComposerDraftsLoaded: () => {},
170 scheduleUnusedComposerAttachmentCleanup: () => {},
171 appendComposerDraftAttachments: vi.fn(),
172 appendComposerDraftText: vi.fn(),
173 mergeComposerDraftContent: vi.fn(),
174 removeComposerDraftAttachment: vi.fn(),
175 useComposerDraft: () => state.draft,
176 useComposerDraftSettings: () => state.draft,
177 questionAttachmentDraftsAtom: () => Atom.make({}),
178 };
179});
180vi.mock("../lib/composerImages", () => ({
181 convertPastedImagesToAttachments: vi.fn(),
182 pickComposerFiles: vi.fn(),
183 pickComposerMedia: vi.fn(),
184}));
185vi.mock("../lib/commandMetadata", () => ({
186 makeQueuedMessageMetadata: () => ({
187 messageId: "message",
188 commandId: "command",
189 createdAt: "2026-09-05T00:00:00.000Z",
190 }),
191}));
192vi.mock("../lib/copyTextWithHaptic", () => ({ copyTextWithHaptic: vi.fn() }));
193vi.mock("./use-remote-environment-registry", () => ({ setPendingConnectionError: vi.fn() }));
194
195import { Alert } from "react-native";
196import { useThreadComposerState } from "./use-thread-composer-state";
197import { useSelectedThreadRequests } from "./use-selected-thread-requests";
198
199const NO_SIDE_CHATS = { startSideChat: async () => false };
200
201beforeEach(() => {
202 state.grantedEnvironments = new Set(["primary"]);
203 state.connectionState = "connected";
204 state.draft = {
205 text: "Keep my draft",
206 attachments: [],
207 modelSelection: undefined,
208 runtimeMode: undefined,
209 };
210 state.freshPending = false;
211 state.compactModel = null;
212 state.compactDueAt = "";
213 state.thread.latestRun = null;
214 state.projection = null;
215 state.serverConfig = {
216 providers: [{ instanceId: "codex", driver: "codex", enabled: true }],
217 environment: { capabilities: {} },
218 } as unknown as ServerConfig;
219 state.pendingRequests = {
220 approvals: [
221 {
222 requestId: RuntimeRequestId.make("approval"),
223 requestKind: "command",
224 createdAt: "2026-09-05T00:00:00.000Z",
225 responseCapability: "live",
226 },
227 ],
228 userInputs: [],
229 };
230 vi.clearAllMocks();
231 state.approve.mockResolvedValue(AsyncResult.success(undefined));
232 state.answer.mockResolvedValue(AsyncResult.success(undefined));
233 state.feedback.mockResolvedValue(AsyncResult.success({ feedbackId: "feedback" }));
234});
235
236describe("mobile task permissions", () => {
237 it("consumes an approval reply without queueing a parent message", async () => {
238 state.grantedEnvironments.add("secondary");
239 const composer = useThreadComposerState(NO_SIDE_CHATS);
240 const requests = useSelectedThreadRequests();
241 const approval = { ...state.pendingRequests.approvals[0]!, supportsFeedback: true };
242 const messageId = await composer.onSendMessage(undefined, async (text) =>
243 (await sendApprovalFeedback(approval, text, requests.onRespondToApproval))
244 ? "responded"
245 : false,
246 );
247 expect(messageId).toBeNull();
248 expect(state.approve).toHaveBeenCalledWith({
249 environmentId: "secondary",
250 input: {
251 threadId: "thread",
252 requestId: "approval",
253 decision: "decline",
254 feedback: "Keep my draft",
255 },
256 });
257 expect(state.enqueue).not.toHaveBeenCalled();
258 expect(state.clearDraft).toHaveBeenCalledOnce();
259 });
260
261 it("preserves a draft changed during an approval reply and never sends it to the parent", async () => {
262 state.grantedEnvironments.add("secondary");
263 const composer = useThreadComposerState(NO_SIDE_CHATS);
264 const beforeDispatch = async () => {
265 state.draft = { ...state.draft, text: "New feedback" };
266 return "responded" as const;
267 };
268 expect(await composer.onSendMessage(undefined, beforeDispatch)).toBeNull();
269 expect(state.enqueue).not.toHaveBeenCalled();
270 expect(state.clearDraft).not.toHaveBeenCalled();
271 expect(state.draft.text).toBe("New feedback");
272 });
273
274 it("keeps a connected read-only task's draft instead of queueing it with another environment's grant", async () => {
275 const composer = useThreadComposerState(NO_SIDE_CHATS);
276 expect(await composer.onSendMessage()).toBeNull();
277 expect(state.enqueue).not.toHaveBeenCalled();
278 expect(state.clearDraft).not.toHaveBeenCalled();
279 composer.onChangeDraftMessage("Edited locally");
280 expect(state.draft.text).toBe("Edited locally");
281 });
282
283 it("keeps an armed fresh session for the message after an approval redirect", async () => {
284 state.grantedEnvironments.add("secondary");
285 state.freshPending = true;
286 const composer = useThreadComposerState(NO_SIDE_CHATS);
287 expect(await composer.onSendMessage(undefined, async () => true)).toBe("message");
288 expect(state.enqueue).toHaveBeenLastCalledWith(
289 expect.not.objectContaining({ freshContext: true }),
290 );
291 expect(state.setFreshPending).not.toHaveBeenCalled();
292 expect(await composer.onSendMessage()).toBe("message");
293 expect(state.enqueue).toHaveBeenLastCalledWith(expect.objectContaining({ freshContext: true }));
294 expect(state.setFreshPending).toHaveBeenCalledWith("secondary:thread", false);
295 });
296
297 it("compacts a stale thread before sending unless kept or a fresh session is armed", async () => {
298 state.grantedEnvironments.add("secondary");
299 state.projection = {
300 thread: {
301 ...state.thread,
302 activeProviderThreadId: null,
303 lineage: { rootThreadId: "thread", parentThreadId: null, relationshipToParent: null },
304 },
305 runs: [],
306 attempts: [],
307 nodes: [],
308 subagents: [],
309 providerSessions: [],
310 providerThreads: [],
311 providerTurns: [],
312 runtimeRequests: [],
313 messages: [],
314 plans: [],
315 turnItems: [],
316 checkpointScopes: [],
317 checkpoints: [],
318 contextHandoffs: [],
319 contextTransfers: [],
320 visibleTurnItems: [],
321 updatedAt: DateTime.makeUnsafe(0),
322 } as unknown as OrchestrationV2ThreadProjection;
323 state.compactModel = "gpt-5.4";
324 // The notice's minute clock (the epoch here) trails a deadline the send's exact time has passed.
325 state.compactDueAt = "2026-01-01T00:00:00.000Z";
326 const unannounced = useThreadComposerState(NO_SIDE_CHATS);
327 expect(unannounced.compactBeforeSend).toBeNull();
328 await unannounced.onSendMessage();
329 expect(state.enqueue).toHaveBeenLastCalledWith(
330 expect.not.objectContaining({ compactBeforeSend: true }),
331 );
332 state.compactDueAt = "";
333 const composer = useThreadComposerState(NO_SIDE_CHATS);
334 expect(composer.compactBeforeSend?.tokens).toBe(248_000);
335 await composer.onSendMessage();
336 expect(state.enqueue).toHaveBeenLastCalledWith(
337 expect.objectContaining({ compactBeforeSend: true }),
338 );
339 // A model picked after the last render still decides the send.
340 state.draft = {
341 ...state.draft,
342 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.5" },
343 };
344 await composer.onSendMessage();
345 expect(state.enqueue).toHaveBeenLastCalledWith(
346 expect.not.objectContaining({ compactBeforeSend: true }),
347 );
348 state.draft = { ...state.draft, modelSelection: undefined };
349 composer.compactBeforeSend?.toggleKeepFullHistory();
350 const kept = useThreadComposerState(NO_SIDE_CHATS);
351 expect(kept.compactBeforeSend?.keepFullHistory).toBe(true);
352 kept.compactBeforeSend?.toggleKeepFullHistory();
353 const compactAgain = useThreadComposerState(NO_SIDE_CHATS);
354 expect(compactAgain.compactBeforeSend?.keepFullHistory).toBe(false);
355 await compactAgain.onSendMessage();
356 expect(state.enqueue).toHaveBeenLastCalledWith(
357 expect.objectContaining({ compactBeforeSend: true }),
358 );
359 compactAgain.compactBeforeSend?.toggleKeepFullHistory();
360 await useThreadComposerState(NO_SIDE_CHATS).onSendMessage();
361 expect(state.enqueue).toHaveBeenLastCalledWith(
362 expect.not.objectContaining({ compactBeforeSend: true }),
363 );
364 // Keeping covers one send, as on web.
365 expect(useThreadComposerState(NO_SIDE_CHATS).compactBeforeSend?.keepFullHistory).toBe(false);
366 state.freshPending = true;
367 await composer.onSendMessage();
368 expect(state.enqueue).toHaveBeenLastCalledWith(
369 expect.not.objectContaining({ compactBeforeSend: true }),
370 );
371 expect(state.enqueue).toHaveBeenLastCalledWith(expect.objectContaining({ freshContext: true }));
372 });
373
374 it("sends a typed /clear with an approval redirect as that reply's text", async () => {
375 state.grantedEnvironments.add("secondary");
376 state.serverConfig = {
377 ...state.serverConfig,
378 environment: { capabilities: { freshContext: true } },
379 } as ServerConfig;
380 state.thread.latestRun = { runId: "run" };
381 state.draft = { ...state.draft, text: "/clear" };
382 const composer = useThreadComposerState(NO_SIDE_CHATS);
383 const beforeDispatch = vi.fn(async () => "responded" as const);
384 expect(await composer.onSendMessage(undefined, beforeDispatch)).toBeNull();
385 expect(beforeDispatch).toHaveBeenCalledWith("/clear", false);
386 expect(state.setFreshPending).not.toHaveBeenCalled();
387 });
388
389 it("starts a side chat for /side instead of sending to the thread", async () => {
390 state.grantedEnvironments.add("secondary");
391 state.serverConfig = {
392 ...state.serverConfig,
393 environment: { capabilities: { sideChats: true } },
394 } as ServerConfig;
395 // The thread gets the composer's mode and model only on its next send.
396 const selection: ModelSelection = {
397 instanceId: ProviderInstanceId.make("codex"),
398 model: "gpt-5.4",
399 options: [{ id: "reasoningEffort", value: "xhigh" }],
400 };
401 state.draft = {
402 ...state.draft,
403 text: "/side why does this fail?",
404 modelSelection: selection,
405 runtimeMode: "approval-required",
406 };
407 const startSideChat = vi.fn(async () => true);
408 const composer = useThreadComposerState({ startSideChat });
409 expect(composer.sideChatUnavailableReason).toBeNull();
410 expect(await composer.onSendMessage()).toBeNull();
411 expect(startSideChat).toHaveBeenCalledExactlyOnceWith(
412 { environmentId: "secondary", threadId: "thread" },
413 "why does this fail?",
414 { runtimeMode: "approval-required", modelSelection: selection },
415 );
416 expect(state.enqueue).not.toHaveBeenCalled();
417 expect(state.clearDraft).toHaveBeenCalled();
418
419 // An attachment added while the side chat starts stays in the draft.
420 vi.clearAllMocks();
421 startSideChat.mockImplementationOnce(async () => {
422 state.draft = {
423 ...state.draft,
424 attachments: [{ id: "added" } as DraftComposerImageAttachment],
425 };
426 return true;
427 });
428 state.draft = { ...state.draft, text: "/side again?", attachments: [] };
429 expect(await useThreadComposerState({ startSideChat }).onSendMessage()).toBeNull();
430 expect(startSideChat).toHaveBeenCalledOnce();
431 expect(state.clearDraft).not.toHaveBeenCalled();
432 // A model the send would refuse does not start a side chat either.
433 vi.clearAllMocks();
434 state.serverConfig = {
435 ...state.serverConfig,
436 providers: [{ instanceId: "codex", driver: "codex", enabled: false }],
437 } as unknown as ServerConfig;
438 state.draft = { ...state.draft, attachments: [] };
439 expect(await useThreadComposerState({ startSideChat }).onSendMessage()).toBeNull();
440 expect(startSideChat).not.toHaveBeenCalled();
441 expect(Alert.alert).toHaveBeenCalledOnce();
442 });
443
444 it("rechecks a retained send callback before clearing or queueing the draft", async () => {
445 state.grantedEnvironments.add("secondary");
446 const composer = useThreadComposerState(NO_SIDE_CHATS);
447 state.grantedEnvironments.delete("secondary");
448 expect(await composer.onSendMessage()).toBeNull();
449 expect(state.clearDraft).not.toHaveBeenCalled();
450 expect(state.enqueue).not.toHaveBeenCalled();
451 state.grantedEnvironments.add("secondary");
452 expect(await composer.onSendMessage()).toBe("message");
453 expect(state.enqueue).toHaveBeenCalledWith(
454 expect.objectContaining({
455 environmentId: "secondary",
456 threadId: "thread",
457 text: "Keep my draft",
458 }),
459 );
460 });
461
462 it("rechecks the target environment before an approval response", async () => {
463 state.grantedEnvironments.add("secondary");
464 const requests = useSelectedThreadRequests();
465 state.grantedEnvironments.delete("secondary");
466 await requests.onRespondToApproval(RuntimeRequestId.make("approval"), "accept");
467 expect(state.approve).not.toHaveBeenCalled();
468 state.grantedEnvironments.add("secondary");
469 await requests.onRespondToApproval(RuntimeRequestId.make("approval"), "accept");
470 expect(state.approve).toHaveBeenCalledWith({
471 environmentId: "secondary",
472 input: { threadId: "thread", requestId: "approval", decision: "accept" },
473 });
474 });
475
476 it("keeps answer drafts editable after revocation while blocking their submission", async () => {
477 state.pendingRequests = {
478 approvals: [],
479 userInputs: [
480 {
481 requestId: RuntimeRequestId.make("input"),
482 createdAt: "2026-09-05T00:00:00.000Z",
483 responseCapability: "live",
484 dismissible: false,
485 questions: [state.question],
486 },
487 ],
488 };
489 const requestId = RuntimeRequestId.make("input");
490 useSelectedThreadRequests().onSelectUserInputOption(requestId, state.question, "TypeScript");
491 state.grantedEnvironments.add("secondary");
492 const requests = useSelectedThreadRequests();
493 expect(requests.activePendingUserInputAnswers).toEqual({ language: "TypeScript" });
494 state.grantedEnvironments.delete("secondary");
495 await requests.onSubmitUserInput();
496 expect(state.answer).not.toHaveBeenCalled();
497 expect(useSelectedThreadRequests().activePendingUserInputAnswers).toEqual({
498 language: "TypeScript",
499 });
500 state.grantedEnvironments.add("secondary");
501 await requests.onSubmitUserInput();
502 expect(state.answer).toHaveBeenCalledWith({
503 environmentId: "secondary",
504 input: { threadId: "thread", requestId: "input", answers: { language: "TypeScript" } },
505 });
506 });
507});
508
509describe("mobile handoff preview and submission", () => {
510 it("follows project enablement and provider availability for the selected destination", async () => {
511 state.grantedEnvironments.add("secondary");
512 const instanceId = ProviderInstanceId.make("antigravity");
513 const projectId = ProjectId.make(state.thread.projectId);
514 const selection = { instanceId, model: "gemini" };
515 const provider = {
516 instanceId,
517 driver: ProviderDriverKind.make("antigravity"),
518 enabled: false,
519 installed: true,
520 auth: { status: "authenticated" as const },
521 availability: "available" as "available" | "unavailable",
522 models: [{ slug: selection.model }],
523 slashCommands: [],
524 };
525 const projectOverride = { enabled: false };
526 state.serverConfig = {
527 ...state.serverConfig,
528 providers: [provider],
529 settings: {
530 ...DEFAULT_SERVER_SETTINGS,
531 providerInstances: { [instanceId]: { driver: provider.driver, enabled: false } },
532 projectSettingsOverrides: {
533 [projectId]: { providerInstances: { [instanceId]: projectOverride } },
534 },
535 },
536 } as unknown as ServerConfig;
537 state.draft.modelSelection = selection;
538 const source = v2Projection.thread.modelSelection;
539 state.projection = {
540 ...v2Projection,
541 runs: [
542 {
543 id: RunId.make("source-run"),
544 threadId: v2Projection.thread.id,
545 ordinal: 1,
546 providerInstanceId: source.instanceId,
547 modelSelection: source,
548 providerThreadId: null,
549 userMessageId: MessageId.make("source-message"),
550 rootNodeId: null,
551 activeAttemptId: null,
552 status: "completed",
553 requestedAt: v2Now,
554 startedAt: v2Now,
555 completedAt: v2Now,
556 checkpointId: null,
557 contextHandoffId: null,
558 },
559 ],
560 visibleTurnItems: [
561 {
562 position: 0,
563 visibility: "local",
564 sourceThreadId: v2Projection.thread.id,
565 sourceItemId: TurnItemId.make("reply"),
566 item: {
567 id: TurnItemId.make("reply"),
568 threadId: v2Projection.thread.id,
569 runId: RunId.make("source-run"),
570 nodeId: null,
571 providerThreadId: null,
572 providerTurnId: null,
573 nativeItemRef: null,
574 parentItemId: null,
575 ordinal: 1,
576 type: "assistant_message",
577 messageId: MessageId.make("reply"),
578 status: "completed",
579 title: null,
580 text: "Task started",
581 streaming: false,
582 startedAt: v2Now,
583 completedAt: v2Now,
584 updatedAt: v2Now,
585 },
586 },
587 ],
588 };
589
590 const disabled = useThreadComposerState(NO_SIDE_CHATS);
591 expect(disabled.pendingHandoff).toBeNull();
592 expect(await disabled.onSendMessage()).toBeNull();
593 expect(state.enqueue).not.toHaveBeenCalled();
594 expect(state.clearDraft).not.toHaveBeenCalled();
595
596 projectOverride.enabled = true;
597 expect(useThreadComposerState(NO_SIDE_CHATS).pendingHandoff).toEqual({
598 from: [source],
599 to: selection,
600 });
601
602 provider.availability = "unavailable";
603 const unavailable = useThreadComposerState(NO_SIDE_CHATS);
604 expect(unavailable.pendingHandoff).toBeNull();
605 expect(await unavailable.onSendMessage()).toBeNull();
606 expect(state.enqueue).not.toHaveBeenCalled();
607 expect(state.clearDraft).not.toHaveBeenCalled();
608
609 provider.availability = "available";
610 const available = useThreadComposerState(NO_SIDE_CHATS);
611 expect(available.pendingHandoff).toEqual({ from: [source], to: selection });
612 expect(await available.onSendMessage()).toBe("message");
613 expect(state.enqueue).toHaveBeenCalledExactlyOnceWith(
614 expect.objectContaining({
615 modelSelection: selection,
616 text: "Keep my draft",
617 }),
618 );
619 expect(state.clearDraft).toHaveBeenCalledTimes(1);
620 });
621});