1import { AuthOrchestrationOperateScope, RuntimeRequestId } from "@t3tools/contracts";
2import type { DraftComposerImageAttachment } from "../lib/composerImages";
3import type { PendingThreadRequests } from "@t3tools/client-runtime/state/thread-requests";
4import { AsyncResult, type Atom } from "effect/reactivity";
5import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
6
7const state = vi.hoisted(() => ({
8 grantedEnvironments: new Set<string>(),
9 connectionState: "connected",
10 draft: { text: "Keep my draft", attachments: [] as DraftComposerImageAttachment[] },
11 pendingRequests: { approvals: [], userInputs: [] } as PendingThreadRequests,
12 enqueue: vi.fn(async () => undefined),
13 clearDraft: vi.fn(),
14 approve: vi.fn(),
15 answer: vi.fn(),
16 feedback: vi.fn(),
17 updateSettings: vi.fn(),
18 question: {
19 id: "language",
20 header: "Language",
21 question: "Which language?",
22 options: [{ label: "TypeScript", description: "Use the existing code" }],
23 multiSelect: false,
24 },
25 thread: {
26 id: "thread",
27 environmentId: "secondary",
28 modelSelection: { instanceId: "codex", model: "gpt-5.4" },
29 runtimeMode: "full-access",
30 interactionMode: "default",
31 runtime: null,
32 latestRun: null,
33 },
34}));
35
36vi.mock("react", () => ({
37 useCallback: <A>(callback: A) => callback,
38 useEffect: () => {},
39 useRef: <A>(current: A) => ({ current }),
40 useMemo: <A>(factory: () => A) => factory(),
41 useState: <A>(initial: A | (() => A)) => [
42 typeof initial === "function" ? (initial as () => A)() : initial,
43 vi.fn(),
44 ],
45}));
46vi.mock("expo-crypto", () => ({ randomUUID: () => "uuid" }));
47vi.mock("../lib/attachmentUpload", () => ({ prepareTurnAttachments: vi.fn() }));
48vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid" }));
49vi.mock("./queued-run-edit", () => ({
50 useQueuedRunEdit: () => null,
51 getQueuedRunEdit: () => null,
52}));
53vi.mock("./preferences", async () => {
54 const { Atom, AsyncResult } = await import("effect/reactivity");
55 return { mobilePreferencesAtom: Atom.make(AsyncResult.success({})) };
56});
57vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } }));
58vi.mock("@effect/atom-react", async () => {
59 const { appAtomRegistry } = await import("./atom-registry");
60 return { useAtomValue: <A>(atom: Atom.Atom<A>) => appAtomRegistry.get(atom) };
61});
62vi.mock("./entities", () => ({ useServerConfigs: () => new Map() }));
63vi.mock("./session", () => ({
64 readEnvironmentScope: (environmentId: string, scope: string) =>
65 scope === AuthOrchestrationOperateScope && state.grantedEnvironments.has(environmentId),
66}));
67vi.mock("./use-thread-selection", () => ({
68 useThreadSelection: () => ({
69 selectedThread: state.thread,
70 selectedThreadCreation: null,
71 selectedEnvironmentRuntime: {
72 connectionState: state.connectionState,
73 serverConfig: {
74 providers: [{ instanceId: "codex", driver: "codex" }],
75 environment: { capabilities: {} },
76 },
77 },
78 }),
79}));
80vi.mock("./use-thread-detail", () => ({
81 useSelectedThreadProjection: () => null,
82 useSelectedThreadVisibleTurnItems: () => [],
83 useSelectedThreadPendingRequests: () => state.pendingRequests,
84}));
85vi.mock("./use-atom-command", () => ({ useAtomCommand: <A>(command: A) => command }));
86vi.mock("./threads", async () => {
87 const { Atom } = await import("effect/reactivity");
88 return {
89 environmentThreadDetails: { queueWorkflowAtom: () => Atom.make(null) },
90 threadEnvironment: {
91 respondToApproval: state.approve,
92 respondToUserInput: state.answer,
93 uploadFeedback: state.feedback,
94 },
95 };
96});
97vi.mock("./use-thread-outbox", async () => {
98 const { Atom } = await import("effect/reactivity");
99 return { dispatchingQueuedMessageIdAtom: Atom.make(null), useThreadOutboxMessages: () => ({}) };
100});
101vi.mock("./thread-outbox", () => ({ enqueueThreadOutboxMessage: state.enqueue }));
102vi.mock("./composer-attachment-uploads", async () => {
103 const { Atom } = await import("effect/reactivity");
104 return {
105 composerAttachmentUploadsAtom: Atom.make({}),
106 composerAttachmentUploadBlockReason: () => null,
107 composerAttachmentsStillUploading: () => false,
108 };
109});
110vi.mock("./use-composer-drafts", async () => {
111 const { Atom } = await import("effect/reactivity");
112 return {
113 composerDraftsAtom: Atom.make({}),
114 composerContextImportsAtom: Atom.make({}),
115 getComposerDraftSnapshot: () => state.draft,
116 clearComposerDraftContent: state.clearDraft,
117 clearComposerDraft: state.clearDraft,
118 setComposerDraftText: (_key: string, text: string) => {
119 state.draft.text = text;
120 },
121 updateComposerDraftSettings: state.updateSettings,
122 ensureComposerDraftsLoaded: () => {},
123 scheduleUnusedComposerAttachmentCleanup: () => {},
124 appendComposerDraftAttachments: vi.fn(),
125 appendComposerDraftText: vi.fn(),
126 mergeComposerDraftContent: vi.fn(),
127 removeComposerDraftAttachment: vi.fn(),
128 useComposerDraft: () => state.draft,
129 };
130});
131vi.mock("../lib/composerImages", () => ({
132 convertPastedImagesToAttachments: vi.fn(),
133 pasteComposerClipboard: vi.fn(),
134 pickComposerFiles: vi.fn(),
135 pickComposerMedia: vi.fn(),
136}));
137vi.mock("../lib/commandMetadata", () => ({
138 makeQueuedMessageMetadata: () => ({
139 messageId: "message",
140 commandId: "command",
141 createdAt: "2026-09-05T00:00:00.000Z",
142 }),
143}));
144vi.mock("../lib/copyTextWithHaptic", () => ({ copyTextWithHaptic: vi.fn() }));
145vi.mock("../lib/modelOptions", () => ({ isModelSelectionUnavailable: () => false }));
146vi.mock("./use-remote-environment-registry", () => ({ setPendingConnectionError: vi.fn() }));
147
148import { useThreadComposerState } from "./use-thread-composer-state";
149import { useSelectedThreadRequests } from "./use-selected-thread-requests";
150
151beforeEach(() => {
152 state.grantedEnvironments = new Set(["primary"]);
153 state.connectionState = "connected";
154 state.draft = { text: "Keep my draft", attachments: [] };
155 state.pendingRequests = {
156 approvals: [
157 {
158 requestId: RuntimeRequestId.make("approval"),
159 requestKind: "command",
160 createdAt: "2026-09-05T00:00:00.000Z",
161 responseCapability: "live",
162 },
163 ],
164 userInputs: [],
165 };
166 vi.clearAllMocks();
167 state.approve.mockResolvedValue(AsyncResult.success(undefined));
168 state.answer.mockResolvedValue(AsyncResult.success(undefined));
169 state.feedback.mockResolvedValue(AsyncResult.success({ feedbackId: "feedback" }));
170});
171
172describe("mobile task permissions", () => {
173 it("keeps a connected read-only task's draft instead of queueing it with another environment's grant", async () => {
174 const composer = useThreadComposerState();
175 expect(await composer.onSendMessage()).toBeNull();
176 expect(state.enqueue).not.toHaveBeenCalled();
177 expect(state.clearDraft).not.toHaveBeenCalled();
178 composer.onChangeDraftMessage("Edited locally");
179 composer.onUpdateInteractionMode("plan");
180 expect(state.draft.text).toBe("Edited locally");
181 expect(state.updateSettings).toHaveBeenCalledWith("secondary:thread", {
182 interactionMode: "plan",
183 });
184 });
185
186 it("rechecks a retained send callback before clearing or queueing the draft", async () => {
187 state.grantedEnvironments.add("secondary");
188 const composer = useThreadComposerState();
189 state.grantedEnvironments.delete("secondary");
190 expect(await composer.onSendMessage()).toBeNull();
191 expect(state.clearDraft).not.toHaveBeenCalled();
192 expect(state.enqueue).not.toHaveBeenCalled();
193 state.grantedEnvironments.add("secondary");
194 expect(await composer.onSendMessage()).toBe("message");
195 expect(state.enqueue).toHaveBeenCalledWith(
196 expect.objectContaining({
197 environmentId: "secondary",
198 threadId: "thread",
199 text: "Keep my draft",
200 }),
201 );
202 });
203
204 it("preserves offline queueing without dispatching feedback through the queue action", async () => {
205 state.connectionState = "disconnected";
206 expect(await useThreadComposerState().onSendMessage()).toBe("message");
207 expect(state.enqueue).toHaveBeenCalledTimes(1);
208 state.draft.text = "/feedback The agent stopped early.";
209 expect(await useThreadComposerState().onSendMessage()).toBeNull();
210 expect(state.feedback).not.toHaveBeenCalled();
211 expect(state.enqueue).toHaveBeenCalledTimes(1);
212 expect(state.clearDraft).toHaveBeenCalledTimes(1);
213 });
214
215 it("rechecks the target environment before an approval response", async () => {
216 state.grantedEnvironments.add("secondary");
217 const requests = useSelectedThreadRequests();
218 state.grantedEnvironments.delete("secondary");
219 await requests.onRespondToApproval(RuntimeRequestId.make("approval"), "accept");
220 expect(state.approve).not.toHaveBeenCalled();
221 state.grantedEnvironments.add("secondary");
222 await requests.onRespondToApproval(RuntimeRequestId.make("approval"), "accept");
223 expect(state.approve).toHaveBeenCalledWith({
224 environmentId: "secondary",
225 input: { threadId: "thread", requestId: "approval", decision: "accept" },
226 });
227 });
228
229 it("keeps answer drafts editable after revocation while blocking their submission", async () => {
230 state.pendingRequests = {
231 approvals: [],
232 userInputs: [
233 {
234 requestId: RuntimeRequestId.make("input"),
235 createdAt: "2026-09-05T00:00:00.000Z",
236 responseCapability: "live",
237 dismissible: false,
238 questions: [state.question],
239 },
240 ],
241 };
242 const requestId = RuntimeRequestId.make("input");
243 useSelectedThreadRequests().onSelectUserInputOption(requestId, state.question, "TypeScript");
244 state.grantedEnvironments.add("secondary");
245 const requests = useSelectedThreadRequests();
246 expect(requests.activePendingUserInputAnswers).toEqual({ language: "TypeScript" });
247 state.grantedEnvironments.delete("secondary");
248 await requests.onSubmitUserInput();
249 expect(state.answer).not.toHaveBeenCalled();
250 expect(useSelectedThreadRequests().activePendingUserInputAnswers).toEqual({
251 language: "TypeScript",
252 });
253 state.grantedEnvironments.add("secondary");
254 await requests.onSubmitUserInput();
255 expect(state.answer).toHaveBeenCalledWith({
256 environmentId: "secondary",
257 input: { threadId: "thread", requestId: "input", answers: { language: "TypeScript" } },
258 });
259 });
260});