| 1 | // Run: tsx src/__tests__/use-controller-send-fallback.test.tsx |
| 2 | |
| 3 | import { JSDOM } from "jsdom"; |
| 4 | import React, { act } from "react"; |
| 5 | import { createRoot } from "react-dom/client"; |
| 6 | import type { AppBindings } from "../lib/bridge"; |
| 7 | import { LocaleProvider, preloadLocale, useI18n } from "../lib/i18n"; |
| 8 | import { useController } from "../lib/useController"; |
| 9 | import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, JobView, Meta, TabMeta, WireEvent } from "../lib/types"; |
| 10 | import { installDesktopHostStub } from "./desktopHostStub"; |
| 11 | |
| 12 | let passed = 0; |
| 13 | let failed = 0; |
| 14 | |
| 15 | function ok(value: boolean, label: string) { |
| 16 | if (value) { |
| 17 | process.stdout.write(` PASS ${label}\n`); |
| 18 | passed += 1; |
| 19 | } else { |
| 20 | process.stdout.write(` FAIL ${label}\n`); |
| 21 | failed += 1; |
| 22 | } |
| 23 | } |
| 24 | |
| 25 | function eq(actual: unknown, expected: unknown, label: string) { |
| 26 | ok(actual === expected, `${label}${actual === expected ? "" : `: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`}`); |
| 27 | } |
| 28 | |
| 29 | function flushPromises(): Promise<void> { |
| 30 | return new Promise((resolve) => setTimeout(resolve, 0)); |
| 31 | } |
| 32 | |
| 33 | function tabMeta(overrides: Partial<TabMeta> = {}): TabMeta { |
| 34 | return { |
| 35 | id: "tab-send", |
| 36 | scope: "project", |
| 37 | workspaceRoot: "/repo/send", |
| 38 | workspaceName: "send", |
| 39 | workspacePath: "/repo/send", |
| 40 | gitBranch: "main", |
| 41 | topicId: "topic-send", |
| 42 | topicTitle: "Send", |
| 43 | label: "model-send", |
| 44 | ready: true, |
| 45 | running: false, |
| 46 | mode: "normal", |
| 47 | toolApprovalMode: "ask", |
| 48 | tokenMode: "full", |
| 49 | active: true, |
| 50 | cwd: "/repo/send", |
| 51 | sessionId: "session-send", |
| 52 | session: { hostId: "local", sessionId: "session-send" }, |
| 53 | sessionGeneration: 1, |
| 54 | runtime: { phase: "ready", epoch: "runtime-send" }, |
| 55 | ...overrides, |
| 56 | }; |
| 57 | } |
| 58 | |
| 59 | function metaFor(tab: TabMeta): Meta { |
| 60 | return { |
| 61 | label: tab.label, |
| 62 | ready: tab.ready, |
| 63 | startupErr: tab.startupErr, |
| 64 | eventChannel: "agent:event", |
| 65 | cwd: tab.cwd || tab.workspaceRoot, |
| 66 | workspaceRoot: tab.workspaceRoot, |
| 67 | workspaceName: tab.workspaceName, |
| 68 | workspacePath: tab.workspacePath, |
| 69 | gitBranch: tab.gitBranch, |
| 70 | sessionId: tab.sessionId, |
| 71 | session: tab.session, |
| 72 | sessionGeneration: tab.sessionGeneration, |
| 73 | runtime: tab.runtime, |
| 74 | autoApproveTools: false, |
| 75 | bypass: false, |
| 76 | collaborationMode: tab.collaborationMode ?? "normal", |
| 77 | toolApprovalMode: tab.toolApprovalMode ?? "ask", |
| 78 | tokenMode: tab.tokenMode ?? "full", |
| 79 | goal: "", |
| 80 | goalStatus: "stopped", |
| 81 | }; |
| 82 | } |
| 83 | |
| 84 | console.log("\nuse controller send fallback"); |
| 85 | |
| 86 | const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", { |
| 87 | pretendToBeVisual: true, |
| 88 | url: "http://localhost/", |
| 89 | }); |
| 90 | (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; |
| 91 | globalThis.window = dom.window as unknown as Window & typeof globalThis; |
| 92 | globalThis.document = dom.window.document; |
| 93 | Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator }); |
| 94 | globalThis.Node = dom.window.Node; |
| 95 | globalThis.HTMLElement = dom.window.HTMLElement; |
| 96 | globalThis.Event = dom.window.Event; |
| 97 | globalThis.CustomEvent = dom.window.CustomEvent; |
| 98 | globalThis.KeyboardEvent = dom.window.KeyboardEvent; |
| 99 | globalThis.MouseEvent = dom.window.MouseEvent; |
| 100 | globalThis.localStorage = dom.window.localStorage; |
| 101 | globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window); |
| 102 | globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window); |
| 103 | |
| 104 | let backendTab = tabMeta({ backgroundJobs: 2 }); |
| 105 | const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 }; |
| 106 | const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] }; |
| 107 | const balance: BalanceInfo = { available: false, display: "" }; |
| 108 | const jobs: JobView[] = []; |
| 109 | const checkpoints: CheckpointMeta[] = []; |
| 110 | let tabsAvailable = false; |
| 111 | let submitCalls = 0; |
| 112 | let rejectSubmit = false; |
| 113 | let rejectAnswer = false; |
| 114 | let rejectAnswerMessage = "prompt write failed"; |
| 115 | let rejectListTabs = false; |
| 116 | let listTabsCalls = 0; |
| 117 | let pendingPromptIdentityCalls = 0; |
| 118 | const exactAnswerCalls: Array<{ tabId: string; turnId: string; promptId: string; answer: unknown }> = []; |
| 119 | const legacyAnswerCalls: string[] = []; |
| 120 | |
| 121 | const desktopStub = installDesktopHostStub(({ |
| 122 | main: { |
| 123 | App: { |
| 124 | ListTabs: async () => { |
| 125 | listTabsCalls += 1; |
| 126 | if (rejectListTabs) throw new Error("runtime status unavailable"); |
| 127 | return tabsAvailable ? [backendTab] : []; |
| 128 | }, |
| 129 | MetaForTab: async () => metaFor(backendTab), |
| 130 | ContextUsageForTab: async () => context, |
| 131 | EffortForTab: async () => effort, |
| 132 | BalanceForTab: async () => balance, |
| 133 | JobsForTab: async () => jobs, |
| 134 | CheckpointsForTab: async () => checkpoints, |
| 135 | ForkTargetsForTab: async () => ({ targets: [], verifiable: false }), |
| 136 | HistoryForTab: async (): Promise<HistoryMessage[]> => [], |
| 137 | HistoryPageForTab: async () => ({ messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }), |
| 138 | HistoryCheckpointTurnsForTab: async () => [], |
| 139 | ReplayPendingPrompts: async () => {}, |
| 140 | ReplayPendingPromptsForTab: async () => {}, |
| 141 | PendingPromptIdentitiesForTab: async (tabId: string) => { |
| 142 | pendingPromptIdentityCalls += tabId === "tab-send" ? 1 : 0; |
| 143 | return backendTab.pendingPrompt && backendTab.turnId ? [{ |
| 144 | promptId: backendTab.turnId === "turn-authoritative" ? "ask-fallback" : "ask-retry", |
| 145 | turnId: backendTab.turnId, |
| 146 | runtimeEpoch: backendTab.runtime?.epoch, |
| 147 | kind: "ask", |
| 148 | }] : []; |
| 149 | }, |
| 150 | SubmitToTab: async (tabId: string) => { |
| 151 | submitCalls += tabId === "tab-send" ? 1 : 0; |
| 152 | }, |
| 153 | SubmitToTabWithID: async (tabId: string) => { |
| 154 | submitCalls += tabId === "tab-send" ? 1 : 0; |
| 155 | if (rejectSubmit) throw new Error("turn already running"); |
| 156 | }, |
| 157 | AnswerQuestionForTab: async (_tabId: string, promptId: string) => { legacyAnswerCalls.push(promptId); }, |
| 158 | ResolvePromptForSession: async (target, answer) => { |
| 159 | exactAnswerCalls.push({ tabId: target.tabId, turnId: target.turnId, promptId: target.promptId, answer }); |
| 160 | if (rejectAnswer && rejectAnswerMessage.includes("not the active turn")) desktopStub.emit("agent:event", { kind: "prompt_answered", tabId: target.tabId, itemId: target.promptId }); |
| 161 | if (rejectAnswer) throw new Error(rejectAnswerMessage); |
| 162 | }, |
| 163 | } as Partial<AppBindings> as AppBindings, |
| 164 | }, |
| 165 | }).main.App); |
| 166 | |
| 167 | type Controller = ReturnType<typeof useController>; |
| 168 | let controller: Controller | undefined; |
| 169 | let setLocale: ReturnType<typeof useI18n>["setPref"] | undefined; |
| 170 | |
| 171 | function Probe() { |
| 172 | setLocale = useI18n().setPref; |
| 173 | controller = useController(); |
| 174 | return null; |
| 175 | } |
| 176 | |
| 177 | const rootEl = document.getElementById("root"); |
| 178 | if (!rootEl) throw new Error("missing root"); |
| 179 | const root = createRoot(rootEl); |
| 180 | |
| 181 | await act(async () => { |
| 182 | root.render(<LocaleProvider><Probe /></LocaleProvider>); |
| 183 | await flushPromises(); |
| 184 | }); |
| 185 | eq(controller?.activeTabId, undefined, "startup has no active tab when backend has no tabs"); |
| 186 | |
| 187 | tabsAvailable = true; |
| 188 | await act(async () => { |
| 189 | await controller?.send("hello from fallback"); |
| 190 | await flushPromises(); |
| 191 | }); |
| 192 | |
| 193 | eq(controller?.activeTabId, "tab-send", "send fallback activates the backend-selected tab"); |
| 194 | eq(controller?.state.backgroundJobs, 2, "send fallback reconciles backend runtime metadata"); |
| 195 | ok(Object.values(controller?.state.localSubmissions ?? {}).some((submission) => submission.text === "hello from fallback"), "send fallback keeps the optimistic user turn"); |
| 196 | eq(submitCalls, 1, "send fallback submits to the activated tab"); |
| 197 | |
| 198 | await act(async () => { |
| 199 | desktopStub.emit("agent:event", { kind: "turn_done", tabId: "tab-send" } as WireEvent); |
| 200 | await flushPromises(); |
| 201 | }); |
| 202 | |
| 203 | backendTab = tabMeta({ running: true, pendingPrompt: true, turnId: "turn-authoritative" }); |
| 204 | await act(async () => { |
| 205 | desktopStub.emit("agent:event", { |
| 206 | kind: "ask_request", |
| 207 | tabId: "tab-send", |
| 208 | runtimeEpoch: "runtime-send", |
| 209 | ask: { id: "ask-fallback", questions: [{ id: "q1", prompt: "Proceed?", options: [{ label: "yes" }] }] }, |
| 210 | } as WireEvent); |
| 211 | await flushPromises(); |
| 212 | }); |
| 213 | eq(controller?.state.activeTurnId, undefined, "Ask fixture starts without a local turn id"); |
| 214 | const beforeAnswerListCalls = listTabsCalls; |
| 215 | const beforePendingPromptIdentityCalls = pendingPromptIdentityCalls; |
| 216 | await act(async () => { |
| 217 | await controller?.answerQuestion("ask-fallback", [{ questionId: "q1", selected: ["yes"] }]); |
| 218 | await flushPromises(); |
| 219 | }); |
| 220 | eq(listTabsCalls, beforeAnswerListCalls, "Ask answer does not borrow the latest tab turn"); |
| 221 | eq(pendingPromptIdentityCalls, beforePendingPromptIdentityCalls + 1, "Ask answer resolves one exact pending-prompt identity"); |
| 222 | eq(exactAnswerCalls.at(-1)?.turnId, "turn-authoritative", "Ask answer uses the authoritative turn fence"); |
| 223 | eq(legacyAnswerCalls.length, 0, "Ask answer never falls back to the unfenced endpoint"); |
| 224 | eq(controller?.state.ask, undefined, "successful exact answer clears the matching Ask without replay"); |
| 225 | |
| 226 | await act(async () => { |
| 227 | desktopStub.emit("agent:event", { |
| 228 | kind: "ask_request", |
| 229 | tabId: "tab-send", |
| 230 | turnId: "turn-authoritative", |
| 231 | runtimeEpoch: "runtime-send", |
| 232 | ask: { id: "ask-retry", questions: [{ id: "q2", prompt: "Retry?", options: [{ label: "yes" }] }] }, |
| 233 | } as WireEvent); |
| 234 | await flushPromises(); |
| 235 | }); |
| 236 | rejectAnswer = true; |
| 237 | let answerRejected = false; |
| 238 | await preloadLocale("zh"); |
| 239 | await act(async () => { |
| 240 | setLocale?.("zh"); |
| 241 | await flushPromises(); |
| 242 | }); |
| 243 | await act(async () => { |
| 244 | try { |
| 245 | await controller?.answerQuestion("ask-retry", [{ questionId: "q2", selected: ["yes"] }]); |
| 246 | } catch { |
| 247 | answerRejected = true; |
| 248 | } |
| 249 | await flushPromises(); |
| 250 | }); |
| 251 | eq(answerRejected, true, "failed exact answer propagates to AskCard"); |
| 252 | eq(controller?.state.ask?.id, "ask-retry", "failed exact answer preserves the pending Ask"); |
| 253 | eq(controller?.state.pendingPrompt, true, "failed exact answer keeps the prompt gate active"); |
| 254 | eq(controller?.state.items.find((item) => item.kind === "notice" && item.text.includes("prompt write failed"))?.text, "提交回答失败:prompt write failed", "failed Ask answer uses the active locale"); |
| 255 | rejectAnswer = false; |
| 256 | |
| 257 | rejectSubmit = true; |
| 258 | await act(async () => { |
| 259 | await controller?.send("continue while prompt is pending").catch(() => {}); |
| 260 | await flushPromises(); |
| 261 | await flushPromises(); |
| 262 | }); |
| 263 | eq(Object.values(controller?.state.localSubmissions ?? {}).some((submission) => submission.text === "continue while prompt is pending" && submission.status === "failed"), true, "colliding submit marks its optimistic bubble failed"); |
| 264 | eq(controller?.state.ask?.id, "ask-retry", "colliding submit preserves the pending Ask"); |
| 265 | eq(controller?.state.running, true, "active backend snapshot keeps the composer blocked after rejection"); |
| 266 | eq(controller?.state.pendingPrompt, true, "active backend snapshot restores the prompt gate after rejection"); |
| 267 | eq(controller?.state.activeTurnId, "turn-authoritative", "active backend snapshot restores the authoritative turn id"); |
| 268 | |
| 269 | await act(async () => { |
| 270 | desktopStub.emit("agent:event", { kind: "turn_done", tabId: "tab-send", turnId: "turn-authoritative" } as WireEvent); |
| 271 | backendTab = tabMeta({ running: false, pendingPrompt: false, turnId: undefined }); |
| 272 | await flushPromises(); |
| 273 | await controller?.send("retry against an idle backend").catch(() => {}); |
| 274 | await flushPromises(); |
| 275 | await flushPromises(); |
| 276 | }); |
| 277 | eq(controller?.state.running, false, "authoritative idle snapshot releases a rejected submit"); |
| 278 | eq(controller?.state.pendingPrompt, false, "authoritative idle snapshot leaves no prompt gate"); |
| 279 | |
| 280 | rejectListTabs = true; |
| 281 | const beforeFailedReconcileCalls = listTabsCalls; |
| 282 | await act(async () => { |
| 283 | await controller?.send("retry while runtime status is unavailable").catch(() => {}); |
| 284 | await new Promise((resolve) => setTimeout(resolve, 1_500)); |
| 285 | }); |
| 286 | eq(listTabsCalls - beforeFailedReconcileCalls, 4, "rejected submit retries failed ListTabs reads at every bounded delay"); |
| 287 | eq(controller?.state.running, true, "exhausted status reads leave the composer conservatively blocked"); |
| 288 | rejectListTabs = false; |
| 289 | |
| 290 | backendTab = tabMeta({ running: true, pendingPrompt: true, turnId: "turn-authoritative" }); |
| 291 | await act(async () => { |
| 292 | desktopStub.emit("agent:event", { |
| 293 | kind: "ask_request", |
| 294 | tabId: "tab-send", |
| 295 | turnId: "turn-authoritative", |
| 296 | runtimeEpoch: "runtime-send", |
| 297 | ask: { id: "ask-stale", questions: [{ id: "q3", prompt: "Stale?", options: [{ label: "yes" }] }] }, |
| 298 | } as WireEvent); |
| 299 | await flushPromises(); |
| 300 | }); |
| 301 | rejectAnswer = true; |
| 302 | rejectAnswerMessage = 'turn "turn-old" is not the active turn for tab "tab-send"'; |
| 303 | await act(async () => { |
| 304 | try { await controller?.answerQuestion("ask-stale", [{ questionId: "q3", selected: ["yes"] }]); } catch {} |
| 305 | await flushPromises(); |
| 306 | }); |
| 307 | eq(controller?.state.ask, undefined, "stale Ask submission expires the old card"); |
| 308 | rejectAnswer = false; |
| 309 | rejectAnswerMessage = "prompt write failed"; |
| 310 | |
| 311 | // Pre-admission rejection does not append a backend event. A fresh idle read |
| 312 | // with the same sequence must still undo each optimistic submission. |
| 313 | rejectSubmit = true; |
| 314 | backendTab = tabMeta({ running: false, pendingPrompt: false, turnEventSeq: 700 }); |
| 315 | await act(async () => { |
| 316 | desktopStub.emit("agent:event", { kind: "turn_done", tabId: "tab-send" } as WireEvent); |
| 317 | await controller?.send("seed idle status after rejected submit").catch(() => {}); |
| 318 | await flushPromises(); |
| 319 | await flushPromises(); |
| 320 | }); |
| 321 | eq(controller?.state.runtimeStatusSeq, 1, "baseline uses the business cut, never the old ledger sequence"); |
| 322 | eq(controller?.state.running, false, "first rejection settles to authoritative idle"); |
| 323 | await act(async () => { |
| 324 | await controller?.send("second pre-admission rejection").catch(() => {}); |
| 325 | await flushPromises(); |
| 326 | await flushPromises(); |
| 327 | }); |
| 328 | eq(controller?.state.running, false, "same-sequence idle settles a new failed submit"); |
| 329 | eq(controller?.state.cancellable, false, "same-sequence idle removes the stale Stop action"); |
| 330 | |
| 331 | await act(async () => { |
| 332 | root.unmount(); |
| 333 | }); |
| 334 | dom.window.close(); |
| 335 | |
| 336 | console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`); |
| 337 | if (failed > 0) process.exit(1); |
| 338 |