| 1 | import { AuthOrchestrationOperateScope, RuntimeRequestId } from "@t3tools/contracts"; |
| 2 | import type { DraftComposerImageAttachment } from "../lib/composerImages"; |
| 3 | import type { PendingThreadRequests } from "@t3tools/client-runtime/state/thread-requests"; |
| 4 | import { AsyncResult, type Atom } from "effect/reactivity"; |
| 5 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 6 | |
| 7 | const 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 | |
| 36 | vi.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 | })); |
| 46 | vi.mock("expo-crypto", () => ({ randomUUID: () => "uuid" })); |
| 47 | vi.mock("../lib/attachmentUpload", () => ({ prepareTurnAttachments: vi.fn() })); |
| 48 | vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid" })); |
| 49 | vi.mock("./queued-run-edit", () => ({ |
| 50 | useQueuedRunEdit: () => null, |
| 51 | getQueuedRunEdit: () => null, |
| 52 | })); |
| 53 | vi.mock("./preferences", async () => { |
| 54 | const { Atom, AsyncResult } = await import("effect/reactivity"); |
| 55 | return { mobilePreferencesAtom: Atom.make(AsyncResult.success({})) }; |
| 56 | }); |
| 57 | vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } })); |
| 58 | vi.mock("@effect/atom-react", async () => { |
| 59 | const { appAtomRegistry } = await import("./atom-registry"); |
| 60 | return { useAtomValue: <A>(atom: Atom.Atom<A>) => appAtomRegistry.get(atom) }; |
| 61 | }); |
| 62 | vi.mock("./entities", () => ({ useServerConfigs: () => new Map() })); |
| 63 | vi.mock("./session", () => ({ |
| 64 | readEnvironmentScope: (environmentId: string, scope: string) => |
| 65 | scope === AuthOrchestrationOperateScope && state.grantedEnvironments.has(environmentId), |
| 66 | })); |
| 67 | vi.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 | })); |
| 80 | vi.mock("./use-thread-detail", () => ({ |
| 81 | useSelectedThreadProjection: () => null, |
| 82 | useSelectedThreadVisibleTurnItems: () => [], |
| 83 | useSelectedThreadPendingRequests: () => state.pendingRequests, |
| 84 | })); |
| 85 | vi.mock("./use-atom-command", () => ({ useAtomCommand: <A>(command: A) => command })); |
| 86 | vi.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 | }); |
| 97 | vi.mock("./use-thread-outbox", async () => { |
| 98 | const { Atom } = await import("effect/reactivity"); |
| 99 | return { dispatchingQueuedMessageIdAtom: Atom.make(null), useThreadOutboxMessages: () => ({}) }; |
| 100 | }); |
| 101 | vi.mock("./thread-outbox", () => ({ enqueueThreadOutboxMessage: state.enqueue })); |
| 102 | vi.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 | }); |
| 110 | vi.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 | }); |
| 131 | vi.mock("../lib/composerImages", () => ({ |
| 132 | convertPastedImagesToAttachments: vi.fn(), |
| 133 | pasteComposerClipboard: vi.fn(), |
| 134 | pickComposerFiles: vi.fn(), |
| 135 | pickComposerMedia: vi.fn(), |
| 136 | })); |
| 137 | vi.mock("../lib/commandMetadata", () => ({ |
| 138 | makeQueuedMessageMetadata: () => ({ |
| 139 | messageId: "message", |
| 140 | commandId: "command", |
| 141 | createdAt: "2026-09-05T00:00:00.000Z", |
| 142 | }), |
| 143 | })); |
| 144 | vi.mock("../lib/copyTextWithHaptic", () => ({ copyTextWithHaptic: vi.fn() })); |
| 145 | vi.mock("../lib/modelOptions", () => ({ isModelSelectionUnavailable: () => false })); |
| 146 | vi.mock("./use-remote-environment-registry", () => ({ setPendingConnectionError: vi.fn() })); |
| 147 | |
| 148 | import { useThreadComposerState } from "./use-thread-composer-state"; |
| 149 | import { useSelectedThreadRequests } from "./use-selected-thread-requests"; |
| 150 | |
| 151 | beforeEach(() => { |
| 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 | |
| 172 | describe("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 | }); |