| 1 | import assert from "node:assert/strict"; |
| 2 | import React, { act, useRef, useState } from "react"; |
| 3 | import { createRoot } from "react-dom/client"; |
| 4 | import { JSDOM } from "jsdom"; |
| 5 | import { useTabProjectionLifecycle } from "../app-runtime/useTabProjectionLifecycle"; |
| 6 | import { useComposerProfileProjection } from "../app-runtime/useComposerProfileProjection"; |
| 7 | import { projectControllerProfiles } from "../app-runtime/controllerProfileOwner"; |
| 8 | import { useSessionOperations } from "../app-runtime/useSessionOperations"; |
| 9 | import { sessionIdentityKey } from "../app-runtime/sessionTarget"; |
| 10 | import { useControllerProfileCommands } from "../lib/useControllerProfileCommands"; |
| 11 | import type { ComposerProfile, UserPlanModeIntents } from "../lib/composerProfile"; |
| 12 | import type { Meta, TabMeta, ToolApprovalMode } from "../lib/types"; |
| 13 | import type { RemoteSessionApi } from "../lib/useRemoteSession"; |
| 14 | |
| 15 | const dom = new JSDOM("<div id='root'></div>"); |
| 16 | Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true }); |
| 17 | const root = createRoot(document.getElementById("root")!); |
| 18 | |
| 19 | const pushes: string[] = []; |
| 20 | const ports = { |
| 21 | model: async () => true, |
| 22 | profile: async (_tab: string, _collaboration: string, approval: string) => { pushes.push(approval); return true; }, |
| 23 | }; |
| 24 | const remoteSession = { composerProfile: undefined } as unknown as RemoteSessionApi; |
| 25 | let patchActive!: ReturnType<typeof useComposerProfileProjection>["patchActiveComposerProfile"]; |
| 26 | |
| 27 | function tabFor(sessionId: string, preset: ToolApprovalMode): TabMeta { |
| 28 | return { id: "surface", active: true, scope: "project", workspaceRoot: "/w", topicId: "topic-" + sessionId, |
| 29 | sessionId, session: { hostId: "local", sessionId }, toolApprovalMode: preset } as unknown as TabMeta; |
| 30 | } |
| 31 | function metaFor(sessionId: string, preset: ToolApprovalMode): Meta { |
| 32 | return { ready: true, sessionId, session: { hostId: "local", sessionId }, toolApprovalMode: preset } as unknown as Meta; |
| 33 | } |
| 34 | |
| 35 | // Mirrors the App composition: backend tab and meta projections hydrate the |
| 36 | // per-tab profile table, and the controller profile owner pushes it back. |
| 37 | function Surface({ tab, meta }: { tab: TabMeta; meta: Meta }) { |
| 38 | const [profiles, setProfiles] = useState<Record<string, ComposerProfile>>({}); |
| 39 | const [, setOrder] = useState<string[]>([]); |
| 40 | const planIntentsRef = useRef<UserPlanModeIntents>({}); |
| 41 | const tabs = [tab]; |
| 42 | useTabProjectionLifecycle({ tabs, activeTabId: tab.id, activeMeta: tab, meta, planIntentsRef, setOrder, setProfiles }); |
| 43 | const projection = useComposerProfileProjection({ activeTabId: tab.id, activeTab: tab, meta, profilesByTab: profiles, |
| 44 | setProfilesByTab: setProfiles, tabMetas: tabs, remote: false, remoteSession, planIntentsRef }); |
| 45 | patchActive = projection.patchActiveComposerProfile; |
| 46 | const target = { tabId: tab.id, sessionKey: sessionIdentityKey({ tabId: tab.id, session: tab.session, sessionId: tab.sessionId, |
| 47 | scope: tab.scope, workspaceRoot: tab.workspaceRoot, topicId: tab.topicId }) }; |
| 48 | const controllerProfiles = projectControllerProfiles(tabs, profiles, { target, profile: projection.composerProfile, remote: false }); |
| 49 | const operations = useSessionOperations({ visible: target, resources: controllerProfiles.map(value => value.target) }); |
| 50 | useControllerProfileCommands({ target, profiles: controllerProfiles, ready: true, remote: false, operations, ports, |
| 51 | remoteModel: async () => {}, report: error => { throw error; } }); |
| 52 | return null; |
| 53 | } |
| 54 | |
| 55 | const paint = (tab: TabMeta, meta: Meta) => act(async () => root.render(<Surface tab={tab} meta={meta} />)); |
| 56 | |
| 57 | try { |
| 58 | for (const tabFirst of [true, false]) { |
| 59 | const order = tabFirst ? "tab list before meta" : "meta before tab list"; |
| 60 | const navigate = async (sessionId: string, preset: ToolApprovalMode, from: { tab: TabMeta; meta: Meta }) => { |
| 61 | if (tabFirst) await paint(tabFor(sessionId, preset), from.meta); |
| 62 | else await paint(from.tab, metaFor(sessionId, preset)); |
| 63 | await paint(tabFor(sessionId, preset), metaFor(sessionId, preset)); |
| 64 | }; |
| 65 | |
| 66 | await paint(tabFor("A", "danger-full-access"), metaFor("A", "danger-full-access")); |
| 67 | pushes.length = 0; |
| 68 | await navigate("B", "workspace-write", { tab: tabFor("A", "danger-full-access"), meta: metaFor("A", "danger-full-access") }); |
| 69 | assert.ok(!pushes.includes("danger-full-access"), `${order}: A's full access was pushed onto B: ${pushes.join(",")}`); |
| 70 | |
| 71 | await act(async () => patchActive({ toolApprovalMode: "read-only" }, ["toolApprovalMode"])); |
| 72 | await paint(tabFor("B", "read-only"), metaFor("B", "read-only")); |
| 73 | pushes.length = 0; |
| 74 | await navigate("A", "danger-full-access", { tab: tabFor("B", "read-only"), meta: metaFor("B", "read-only") }); |
| 75 | assert.deepEqual(pushes.filter(approval => approval !== "danger-full-access"), [], |
| 76 | `${order}: a push after returning to A overwrote its restored preset: ${pushes.join(",")}`); |
| 77 | |
| 78 | await act(async () => root.render(null)); |
| 79 | } |
| 80 | console.log("composer profile session navigation: no push carries another session's preset"); |
| 81 | } finally { |
| 82 | await act(async () => root.unmount()); |
| 83 | dom.window.close(); |
| 84 | } |
| 85 |