| 1 | import assert from "node:assert/strict"; |
| 2 | import { act } from "react"; |
| 3 | import { installDom, installBridgeApp, renderComposer } from "./composerInboxHarness"; |
| 4 | import { makeSessionUIMock } from "../lib/sessionUIMock"; |
| 5 | import { runtimeStateStore, type RuntimeProjection } from "../lib/runtimeStateStore"; |
| 6 | import type { InboxTarget } from "../lib/pendingFollowup"; |
| 7 | import type { InboxQueueRequest } from "../lib/inboxQueueCommands"; |
| 8 | |
| 9 | async function waitFor(check: () => boolean) { |
| 10 | for (let attempt = 0; attempt < 100 && !check(); attempt++) { |
| 11 | await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); }); |
| 12 | } |
| 13 | assert.ok(check(), document.body.textContent || "composer did not become ready"); |
| 14 | } |
| 15 | |
| 16 | for (const phase of ["executing", "finishing"] as const) { |
| 17 | const dom = installDom(); |
| 18 | const sessionId = `canonical-${phase}`; |
| 19 | const ref = { hostId: "local", sessionId }; |
| 20 | const route = `session-id:${sessionId}`; |
| 21 | const backend = makeSessionUIMock(async () => {}); |
| 22 | const target: InboxTarget = { tabId: "tab-a", sessionPath: route, generation: 3, selection: 0, remote: false }; |
| 23 | let queues = 0, steers = 0, lookups = 0, direct = 0; |
| 24 | let lost = false, conflict = false, lastKey = ""; |
| 25 | const receipt = (key: string) => ({ itemId: key, disposition: "queued_followup", position: 1, paused: false }); |
| 26 | installBridgeApp({ |
| 27 | ...backend, |
| 28 | BeginSessionComposerSubmission: async (...args: Parameters<typeof backend.BeginSessionComposerSubmission>) => { |
| 29 | if (conflict) { |
| 30 | conflict = false; |
| 31 | const state = await backend.GetSessionComposerState(ref); |
| 32 | await backend.SaveSessionComposerState({ ref, expectedRevision: state.revision, contentVersion: 1, contentJson: '{"text":"another window"}' }); |
| 33 | throw new Error("session_operation:input_conflict:The input changed"); |
| 34 | } |
| 35 | return backend.BeginSessionComposerSubmission(...args); |
| 36 | }, |
| 37 | CaptureInboxTarget: async (tabId: string, path: string) => { |
| 38 | assert.equal(tabId, "tab-a"); assert.equal(path, route); |
| 39 | return target; |
| 40 | }, |
| 41 | EnqueueInboxFollowupForTarget: async (captured: InboxTarget, _display: string, _submit: string, _invocations: unknown, key: string) => { |
| 42 | assert.deepEqual(captured, target); queues++; lastKey = key; |
| 43 | if (lost) throw new Error("accepted reply lost"); |
| 44 | return receipt(key); |
| 45 | }, |
| 46 | InboxQueueForTarget: async (captured: InboxTarget, request: InboxQueueRequest) => { |
| 47 | assert.deepEqual(captured, target); assert.equal(request.kind, "enqueue_steer"); |
| 48 | assert.equal(request.turnId, "active-turn"); steers++; |
| 49 | return { outcome: "applied", receipt: { ...receipt(request.idempotencyKey!), disposition: "steer_accepted" } }; |
| 50 | }, |
| 51 | LookupInboxFollowupForTarget: async (captured: InboxTarget, key: string) => { |
| 52 | assert.deepEqual(captured, target); assert.equal(key, lastKey); lookups++; |
| 53 | return receipt(key); |
| 54 | }, |
| 55 | ListTabs: async () => [{ id: "tab-a", session: ref, sessionPath: "", turnId: "active-turn" }], |
| 56 | InboxSnapshot: async () => ({ revision: 1, sessionPath: route, mutationsSupported: true, items: [], itemsCount: 0, paused: false }), |
| 57 | }); |
| 58 | const projection: RuntimeProjection = { epoch: sessionId, revision: 1, topics: [], sessions: [{ |
| 59 | tabId: "tab-a", scope: "global", workspaceRoot: "", topicId: "topic", sessionId, sessionPath: "", sessionGeneration: 3, |
| 60 | open: true, remote: false, freshness: "synced", state: { schemaVersion: 1, runtimeEpoch: "runtime", activityRevision: 1, revision: 1, |
| 61 | phase, running: true, turnId: "active-turn", turnStatus: "running", turnEventSeq: 1, pendingPrompt: false, |
| 62 | cancelRequested: false, cancellable: phase === "executing", backgroundJobs: 0, activity: "streaming" }, |
| 63 | }] }; |
| 64 | runtimeStateStore.commit(projection); |
| 65 | const view = await renderComposer({ running: false, formalSessionRef: ref, sessionKey: sessionId, |
| 66 | sessionIdentity: { session: ref, sessionPath: "", sessionGeneration: 3 }, inboxSessionPath: route, |
| 67 | onSend: () => { direct++; } }); |
| 68 | let insert = 0; |
| 69 | const input = () => document.querySelector<HTMLTextAreaElement>("textarea.composer__input:not([aria-hidden=true])")!; |
| 70 | async function compose(text: string) { |
| 71 | await waitFor(() => !!input() && !input().disabled); |
| 72 | await view.rerender({ insertRequest: { id: ++insert, text, mode: "replace" } }); |
| 73 | } |
| 74 | async function click(selector: string) { |
| 75 | const button = document.querySelector<HTMLButtonElement>(selector); |
| 76 | assert.ok(button && !button.disabled, `missing enabled ${selector}`); |
| 77 | await act(async () => { button.click(); }); |
| 78 | } |
| 79 | try { |
| 80 | await compose("queue while model runs"); |
| 81 | await click(".composer__btn--send"); |
| 82 | await waitFor(() => queues === 1 && input().value === "" && !input().disabled); |
| 83 | await compose("guide current turn"); |
| 84 | // Finishing sessions must retain the instruction as a next-turn message. |
| 85 | await click(phase === "executing" ? ".composer__queue-steer" : ".composer__btn--send"); |
| 86 | await waitFor(() => queues + steers === 2 && input().value === "" && !input().disabled); |
| 87 | assert.equal(steers, phase === "executing" ? 1 : 0); |
| 88 | assert.equal(direct, 0, "canonical runtime phase must prevent an ordinary direct send"); |
| 89 | |
| 90 | await compose("retain conflicted guidance"); |
| 91 | conflict = true; |
| 92 | const sendButton = phase === "executing" ? ".composer__queue-steer" : ".composer__btn--send"; |
| 93 | await click(sendButton); |
| 94 | await waitFor(() => !input().disabled); |
| 95 | assert.doesNotMatch(document.body.textContent || "", /核实提交状态|使用已保存版本|保留本窗口版本/); |
| 96 | assert.equal(input().value, "retain conflicted guidance"); |
| 97 | assert.equal(queues + steers, 2, "registration rejection never reached the inbox"); |
| 98 | await click(sendButton); |
| 99 | await waitFor(() => queues + steers === 3 && input().value === "" && !input().disabled); |
| 100 | |
| 101 | await compose("recover a lost queue receipt"); |
| 102 | lost = true; |
| 103 | await click(".composer__btn--send"); |
| 104 | await waitFor(() => input().disabled && !!document.querySelector(".session-draft-surface__error")); |
| 105 | const before = queues; |
| 106 | await click(".composer__btn--send"); |
| 107 | await waitFor(() => lookups === 1 && input().value === "" && !input().disabled); |
| 108 | assert.equal(queues, before, "receipt recovery must not post the instruction twice"); |
| 109 | assert.equal((await backend.GetSessionComposerState(ref)).submissionId, undefined); |
| 110 | assert.equal(document.querySelector(".session-draft-surface__error"), null); |
| 111 | } finally { |
| 112 | await act(async () => view.root.unmount()); |
| 113 | dom.window.close(); |
| 114 | } |
| 115 | } |
| 116 | console.log("canonical composer: running queue, current-turn guidance, finishing and lost receipt recovery passed"); |
| 117 |