{"repo": "chikhamx/voidx", "files": {"frontend/test/ui/workbench.test.ts": {"b4e14b932564afe825a829008e5229630f9c40a4": {"t": 1786068457.0252879, "n": 45171, "h": "6297a4b71d25a26f", "c": "// @ts-nocheck\nimport { readFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { beforeEach, describe, expect, it, vi } from \"vitest\";\n\nconst tauriMocks = vi.hoisted(() => ({\n openDialog: vi.fn(),\n invoke: vi.fn(),\n}));\n\nvi.mock(\"@tauri-apps/plugin-dialog\", () => ({\n open: tauriMocks.openDialog,\n}));\n\nvi.mock(\"@tauri-apps/api/core\", () => ({\n invoke: tauriMocks.invoke,\n}));\n\nimport { handleNotification, initModelControls, resolveWsUrl, _resetWorkbenchForTest } from \"../../src/main\";\nimport { initPermissionControls, populatePermissionDropdown } from \"../../src/ui/model\";\nimport { _resetForTest as resetDock, initDock, switchTab, toggleDock, getActiveTab } from \"../../src/ui/dock\";\nimport { _setSocket, _resetForTest as resetRpc } from \"../../src/rpc\";\n\nfunction readCombinedStyles(filePath: string): string {\n let content = readFileSync(filePath, \"utf8\");\n const importRegex = /@import\\s+url\\([\"']([^\"']+)[\"']\\);/g;\n content = content.replace(importRegex, (_, importPath) => {\n const absoluteImportPath = join(join(filePath, \"..\"), importPath);\n return readCombinedStyles(absoluteImportPath);\n });\n return content;\n}\n\nfunction readStylesCSS(): string {\n return readCombinedStyles(join(process.cwd(), \"css/styles.css\"));\n}\n\nfunction sentPayloads(sentMessages) {\n return sentMessages.map((raw) => JSON.parse(raw));\n}\n\nfunction setupOpenSocket() {\n const sentMessages = [];\n const socket = {\n readyState: WebSocket.OPEN,\n send: (message) => sentMessages.push(message),\n };\n _setSocket(socket);\n return sentMessages;\n}\n\nfunction setupOpenSocketWithHandle() {\n const sentMessages = [];\n const socket = {\n readyState: WebSocket.OPEN,\n onmessage: null,\n send: (message) => sentMessages.push(message),\n };\n _setSocket(socket);\n return { sentMessages, socket };\n}\n\nbeforeEach(() => {\n resetRpc();\n resetDock();\n _resetWorkbenchForTest();\n tauriMocks.openDialog.mockReset();\n tauriMocks.invoke.mockReset();\n delete window.__TAURI_INTERNALS__;\n delete window.__TAURI__;\n initDock();\n initModelControls();\n initPermissionControls();\n});\n\ndescribe(\"workbench shell\", () => {\n it(\"renders the fixed sidebar navigation and project sections\", () => {\n const sidebar = document.querySelector(\"#sidebar\");\n expect(sidebar.textContent).toContain(\"新对话\");\n expect(sidebar.textContent).toContain(\"搜索\");\n expect(sidebar.textContent).not.toContain(\"已安排\");\n expect(sidebar.textContent).toContain(\"项目\");\n expect(sidebar.textContent).not.toContain(\"历史会话\");\n expect(document.querySelector(\".vx-project-heading .vx-sidebar-row-icon svg\")).not.toBeNull();\n expect(document.querySelector(\".vx-project-heading-label\").textContent).toBe(\"项目\");\n expect(document.querySelector(\"#project-list\")).toBeNull();\n expect(document.querySelector(\"#btn-integrations\").hidden).toBe(true);\n });\n\n\n it(\"integrations button requests integration snapshot\", () => {\n const sentMessages = setupOpenSocket();\n\n document.querySelector(\"#btn-integrations\").click();\n\n expect(sentPayloads(sentMessages)[0]).toMatchObject({\n method: \"integrations.get\",\n params: {},\n });\n });\n\n it(\"shows empty-state prompt while transcript has no content\", () => {\n const emptyState = document.querySelector(\"#empty-state\");\n const transcript = document.querySelector(\"#transcript\");\n const mainCanvas = document.querySelector(\".vx-main-canvas\");\n expect(transcript.children).toHaveLength(0);\n expect(emptyState.hidden).toBe(false);\n expect(mainCanvas.classList.contains(\"empty\")).toBe(true);\n expect(emptyState.textContent).toContain(\"让我们一起让世界变得更加美好!\");\n });\n\n it(\"does not render the redundant configured permission pill in the composer\", () => {\n expect(document.querySelector(\"#permission-pill\")).toBeNull();\n expect(document.querySelector(\"#status-permission\")).toBeNull();\n expect(document.querySelector(\"#context-permission\")).toBeNull();\n expect(document.querySelector(\"#strip-permission\")).toBeNull();\n });\n\n it(\"hides empty-state prompt once a live conversation item starts\", () => {\n const emptyState = document.querySelector(\"#empty-state\");\n const mainCanvas = document.querySelector(\".vx-main-canvas\");\n\n handleNotification(\"item.started\", {\n kind: \"assistant_stream\",\n item_id: \"stream-1\",\n data: { phase: \"thinking\" },\n });\n\n expect(emptyState.hidden).toBe(true);\n expect(mainCanvas.classList.contains(\"empty\")).toBe(false);\n });\n\n it(\"centers the composer in an empty new conversation\", () => {\n const styles = readStylesCSS();\n\n expect(styles).toMatch(/\\.vx-main-canvas\\.empty \\{[^}]*justify-content: center;[^}]*\\}/);\n expect(styles).toMatch(/\\.vx-main-canvas\\.empty \\.vx-empty-state \\{[^}]*margin-bottom: var\\(--vx-space-8\\);[^}]*\\}/);\n expect(styles).toMatch(/\\.vx-main-canvas\\.empty \\.composer \\{[^}]*margin-bottom: 0;[^}]*\\}/);\n });\n\n it(\"renders current workspace in the workspace session tree\", () => {\n handleNotification(\"startup.shown\", {\n workspace: \"/Users/chikham/workspace/voidx\",\n provider: \"openai\",\n model: \"gpt-5.5\",\n profile_configured: true,\n });\n handleNotification(\"workspace.snapshot\", {\n active_thread_id: \"t1\",\n active_snapshot: { thread_id: \"t1\", nodes: [] },\n threads: [{ thread_id: \"t1\", title: \"Default\", workspace: \"/Users/chikham/workspace/voidx\" }],\n workspace: \"/Users/chikham/workspace/voidx\",\n provider: \"openai\",\n model: \"gpt-5.5\",\n profile_configured: true,\n });\n\n const activeWorkspace = document.querySelector(\".vx-workspace-session-group.active .vx-workspace-session-name\");\n expect(activeWorkspace).not.toBeNull();\n expect(activeWorkspace.textContent).toContain(\"voidx\");\n });\n\n it(\"opens a workspace folder from the project add button\", async () => {\n window.__TAURI_INTERNALS__ = {};\n tauriMocks.openDialog.mockResolvedValue(\"/Users/chikham/workspace/imcore-sdk\");\n tauriMocks.invoke.mockImplementation((command: string) => {\n if (command === \"restart_backend\") {\n return Promise.resolve({ status: \"restarting\" });\n }\n if (command === \"get_gateway_url\") {\n return Promise.resolve(\"\");\n }\n return Promise.resolve(null);\n });\n\n handleNotification(\"startup.shown\", {\n workspace: \"/Users/chikham/workspace/voidx\",\n provider: \"openai\",\n model: \"gpt-5.5\",\n profile_configured: true,\n });\n\n document.querySelector(\"#btn-open-workspace\").click();\n\n await vi.waitFor(() => {\n expect(tauriMocks.openDialog).toHaveBeenCalledWith({\n directory: true,\n multiple: false,\n title: \"选择项目文件夹\",\n });\n });\n await vi.waitFor(() => {\n expect(tauriMocks.invoke).toHaveBeenCalledWith(\"restart_backend\", {\n workspace: \"/Users/chikham/workspace/imcore-sdk\",\n });\n });\n expect(document.querySelector(\"#project-list\")).toBeNull();\n expect(document.querySelector(\"#status-workspace-detail\").textContent).toBe(\"imcore-sdk\");\n });\n\n it(\"resolves gateway url through tauri invoke without relying on tauri globals\", async () => {\n tauriMocks.invoke.mockResolvedValue(\"ws://127.0.0.1:12345/?token=test\");\n\n const url = await resolveWsUrl();\n\n expect(url).toBe(\"ws://127.0.0.1:12345/?token=test\");\n expect(tauriMocks.invoke).toHaveBeenCalledWith(\"get_gateway_url\");\n });\n\n it(\"keeps sidebar rows aligned in the workbench layout\", () => {\n const styles = readStylesCSS();\n\n expect(styles).toContain(\".vx-nav-item,\\n.vx-directory-row,\\n.vx-session-item\");\n expect(document.querySelector(\"#sidebar-resizer\")).not.toBeNull();\n expect(styles).toContain(\".vx-sidebar-nav,\\n.vx-sidebar-footer\");\n expect(styles).toContain(\".vx-session-children {\\n display: grid;\");\n expect(styles).toContain(\"padding-left: var(--vx-space-4);\");\n expect(styles).toMatch(/\\.vx-sidebar-section \\{[^}]*flex: 1;[^}]*min-height: 0;[^}]*\\}/);\n expect(styles).toMatch(/\\.vx-session-item \\{[^}]*grid-template-columns: 16px minmax\\(0, 1fr\\) max-content;[^}]*\\}/);\n expect(styles).toMatch(/\\.vx-session-time \\{[^}]*justify-self: end;[^}]*\\}/);\n expect(styles).toMatch(/\\.vx-nav-item,[\\s\\S]*\\.vx-directory-row,[\\s\\S]*\\.vx-session-item \\{[^}]*padding: 0 var\\(--vx-space-2\\);[^}]*\\}/);\n expect(styles).toMatch(/\\.vx-sidebar-heading \\{[^}]*color: var\\(--vx-text-muted\\);[^}]*font-size: var\\(--vx-text-xs\\);[^}]*min-height: 28px;[^}]*\\}/);\n expect(styles).toMatch(/\\.vx-sidebar-row-icon \\{[^}]*color: var\\(--vx-text-muted\\);[^}]*\\}/);\n expect(styles).toMatch(/\\.vx-sidebar-resizer \\{[^}]*cursor: col-resize;[^}]*\\}/);\n expect(styles).toMatch(/\\.vx-dock \\{[^}]*border-left: 1px solid var\\(--vx-border\\);[^}]*width: var\\(--vx-dock-width\\);[^}]*\\}/);\n expect(styles).toContain(\".vx-sidebar-row-icon\");\n });\n\n it(\"resizes the sidebar from the draggable boundary\", () => {\n const resizer = document.querySelector(\"#sidebar-resizer\");\n\n resizer.dispatchEvent(new PointerEvent(\"pointerdown\", { clientX: 260, pointerId: 1 }));\n window.dispatchEvent(new PointerEvent(\"pointermove\", { clientX: 340, pointerId: 1 }));\n window.dispatchEvent(new PointerEvent(\"pointerup\", { clientX: 340, pointerId: 1 }));\n\n expect(document.querySelector(\".vx-workbench-shell\").style.getPropertyValue(\"--vx-sidebar-width\")).toBe(\"340px\");\n });\n\n it(\"reuses the existing empty session for a workspace instead of creating duplicates\", async () => {\n const { sentMessages, socket } = setupOpenSocketWithHandle();\n\n handleNotification(\"workspace.snapshot\", {\n active_thread_id: \"filled\",\n active_snapshot: { thread_id: \"filled\", nodes: [] },\n threads: [\n {\n thread_id: \"empty-1\",\n title: \"New session\",\n status: \"idle\",\n workspace: \"/Users/chikham/workspace/voidx\",\n },\n {\n thread_id: \"filled\",\n title: \"Existing work\",\n status: \"idle\",\n workspace: \"/Users/chikham/workspace/voidx\",\n },\n ],\n workspace: \"/Users/chikham/workspace/voidx\",\n provider: \"openai\",\n model: \"gpt-5.5\",\n profile_configured: true,\n });\n sentMessages.length = 0;\n\n document.querySelector(\"#btn-new-chat\").click();\n\n await vi.waitFor(() => {\n expect(sentPayloads(sentMessages).some((payload) => payload.method === \"session.switch\")).toBe(true);\n });\n expect(sentPayloads(sentMessages).find((payload) => payload.method === \"session.switch\")).toMatchObject({\n method: \"session.switch\",\n params: { thread_id: \"empty-1\" },\n });\n expect(sentPayloads(sentMessages).some((payload) => payload.method === \"session.create\")).toBe(false);\n\n const request = sentPayloads(sentMessages).find((payload) => payload.method === \"session.switch\");\n socket.onmessage({\n data: JSON.stringify({\n jsonrpc: \"2.0\",\n id: request.id,\n result: { active_thread_id: \"empty-1\" },\n }),\n });\n\n await vi.waitFor(() => {\n expect(document.querySelector(\"#status-session-detail\").textContent).toContain(\"empty-1\");\n });\n document.querySelector(\".vx-workspace-session-row\")?.click();\n await vi.waitFor(() => {\n expect(document.querySelector(\".vx-session-item.active\").dataset.threadId).toBe(\"empty-1\");\n });\n });\n\n it(\"shows cancel state immediately after submitting a message and sends cancel\", async () => {\n const sentMessages = setupOpenSocket();\n const input = document.querySelector(\"#input\");\n const send = document.querySelector(\"#btn-send\");\n\n handleNotification(\"workspace.snapshot\", {\n active_thread_id: \"t1\",\n active_snapshot: { thread_id: \"t1\", nodes: [] },\n threads: [{ thread_id: \"t1\", title: \"Default\", workspace: \"/Users/chikham/workspace/voidx\" }],\n workspace: \"/Users/chikham/workspace/voidx\",\n provider: \"deepseek\",\n model: \"deepseek-chat\",\n profile_configured: true,\n });\n sentMessages.length = 0;\n\n input.value = \"你好\";\n document.querySelector(\"#composer\").dispatchEvent(new SubmitEvent(\"submit\", { bubbles: true, cancelable: true }));\n\n expect(send.classList.contains(\"running\")).toBe(true);\n expect(send.querySelector(\"svg.vx-icon\")).not.toBeNull();\n expect(input.disabled).toBe(false);\n expect(sentPayloads(sentMessages).find((payload) => payload.method === \"session.submit\")).toMatchObject({\n method: \"session.submit\",\n params: { text: \"你好\", thread_id: \"t1\" },\n });\n\n send.click();\n\n expect(sentPayloads(sentMessages).some((payload) => payload.method === \"session.cancel\")).toBe(true);\n });\n\n it(\"keeps input enabled while a turn is running\", () => {\n const input = document.querySelector(\"#input\");\n handleNotification(\"turn.started\", {});\n expect(input.disabled).toBe(false);\n });\n\n it(\"clears input without sending when submitting an unknown slash command\", () => {\n const sentMessages = setupOpenSocket();\n const input = document.querySelector(\"#input\");\n\n handleNotification(\"workspace.snapshot\", {\n active_thread_id: \"t1\",\n active_snapshot: { thread_id: \"t1\", nodes: [] },\n threads: [{ thread_id: \"t1\", title: \"Default\", workspace: \"\" }],\n workspace: \"\",\n provider: \"deepseek\",\n model: \"deepseek-chat\",\n profile_configured: true,\n });\n sentMessages.length = 0;\n\n input.value = \"/zzz\";\n document.querySelector(\"#composer\").dispatchEvent(new SubmitEvent(\"submit\", { bubbles: true, cancelable: true }));\n\n expect(input.value).toBe(\"\");\n expect(sentPayloads(sentMessages).filter((p) => p.method === \"session.submit\")).toHaveLength(0);\n });\n\n it(\"sends known slash command without args instead of clearing input\", () => {\n const sentMessages = setupOpenSocket();\n const input = document.querySelector(\"#input\");\n\n handleNotification(\"workspace.snapshot\", {\n active_thread_id: \"t1\",\n active_snapshot: { thread_id: \"t1\", nodes: [] },\n threads: [{ thread_id: \"t1\", title: \"Default\", workspace: \"\" }],\n workspace: \"\",\n provider: \"deepseek\",\n model: \"deepseek-chat\",\n profile_configured: true,\n });\n sentMessages.length = 0;\n\n input.value = \"/help\";\n document.querySelector(\"#composer\").dispatchEvent(new SubmitEvent(\"submit\", { bubbles: true, cancelable: true }));\n\n expect(sentPayloads(sentMessages).some((p) => p.method === \"session.submit\" && p.params.text === \"/help\")).toBe(true);\n });\n\n it(\"sends known slash command with args instead of clearing input\", () => {\n const sentMessages = setupOpenSocket();\n const input = document.querySelector(\"#input\");\n\n handleNotification(\"workspace.snapshot\", {\n active_thread_id: \"t1\",\n active_snapshot: { thread_id: \"t1\", nodes: [] },\n threads: [{ thread_id: \"t1\", title: \"Default\", workspace: \"\" }],\n workspace: \"\",\n provider: \"deepseek\",\n model: \"deepseek-chat\",\n profile_configured: true,\n });\n sentMessages.length = 0;\n\n input.value = \"/lang en\";\n document.querySelector(\"#composer\").dispatchEvent(new SubmitEvent(\"submit\", { bubbles: true, cancelable: true }));\n\n expect(sentPayloads(sentMessages).some((p) => p.method === \"session.submit\" && p.params.text === \"/lang en\")).toBe(true);\n });\n\n\n it(\"sends /loop prompts with attachment tokens instead of clearing input\", () => {\n const sentMessages = setupOpenSocket();\n const input = document.querySelector(\"#input\");\n\n handleNotification(\"workspace.snapshot\", {\n active_thread_id: \"t1\",\n active_snapshot: { thread_id: \"t1\", nodes: [] },\n threads: [{ thread_id: \"t1\", title: \"Default\", workspace: \"\" }],\n workspace: \"\",\n provider: \"deepseek\",\n model: \"deepseek-chat\",\n profile_configured: true,\n });\n sentMessages.length = 0;\n\n input.value = \"/loop 5m @docs/review.md\";\n document.querySelector(\"#composer\").dispatchEvent(new SubmitEvent(\"submit\", { bubbles: true, cancelable: true }));\n\n expect(sentPayloads(sentMessages).some((p) => p.method === \"session.submit\" && p.params.text === \"/loop 5m @docs/review.md\")).toBe(true);\n });\n\n it(\"shows guidance pending state when submitting during a running turn\", async () => {\n const sentMessages = setupOpenSocket();\n const input = document.querySelector(\"#input\");\n const send = document.querySelector(\"#btn-send\");\n\n handleNotification(\"workspace.snapshot\", {\n active_thread_id: \"t1\",\n active_snapshot: { thread_id: \"t1\", nodes: [] },\n threads: [{ thread_id: \"t1\", title: \"Default\", workspace: \"\" }],\n workspace: \"\",\n provider: \"deepseek\",\n model: \"deepseek-chat\",\n profile_configured: true,\n });\n handleNotification(\"turn.started\", {});\n sentMessages.length = 0;\n\n input.value = \"keep going\";\n document.querySelector(\"#composer\").dispatchEvent(new SubmitEvent(\"submit\", { bubbles: true, cancelable: true }));\n\n expect(send.classList.contains(\"guidance-pending\")).toBe(true);\n expect(sentPayloads(sentMessages).find((p) => p.method === \"session.submit\")).toMatchObject({\n method: \"session.submit\",\n params: { text: \"keep going\", thread_id: \"t1\" },\n });\n });\n\n it(\"clears guidance pending state after RPC resolves\", async () => {\n const { sentMessages, socket } = setupOpenSocketWithHandle();\n const input = document.querySelector(\"#input\");\n const send = document.querySelector(\"#btn-send\");\n\n handleNotification(\"workspace.snapshot\", {\n active_thread_id: \"t1\",\n active_snapshot: { thread_id: \"t1\", nodes: [] },\n threads: [{ thread_id: \"t1\", title: \"Default\", workspace: \"\" }],\n workspace: \"\",\n provider: \"deepseek\",\n model: \"deepseek-chat\",\n profile_configured: true,\n });\n handleNotification(\"turn.started\", {});\n sentMessages.length = 0;\n\n input.value = \"keep going\";\n document.querySelector(\"#composer\").dispatchEvent(new SubmitEvent(\"submit\", { bubbles: true, cancelable: true }));\n\n expect(send.classList.contains(\"guidance-pending\")).toBe(true);\n\n const submitMsg = sentPayloads(sentMessages).find((p) => p.method === \"session.submit\");\n expect(submitMsg).toBeDefined();\n socket.onmessage({ data: JSON.stringify({ jsonrpc: \"2.0\", id: submitMsg.id, result: { ok: true } }) } as MessageEvent);\n\n await vi.waitFor(() => {\n expect(send.classList.contains(\"guidance-pending\")).toBe(false);\n });\n });\n\n it(\"preserves input text when guidance submission fails\", async () => {\n const { sentMessages, socket } = setupOpenSocketWithHandle();\n const input = document.querySelector(\"#input\");\n const send = document.querySelector(\"#btn-send\");\n\n handleNotification(\"workspace.snapshot\", {\n active_thread_id: \"t1\",\n active_snapshot: { thread_id: \"t1\", nodes: [] },\n threads: [{ thread_id: \"t1\", title: \"Default\", workspace: \"\" }],\n workspace: \"\",\n provider: \"deepseek\",\n model: \"deepseek-chat\",\n profile_configured: true,\n });\n handleNotification(\"turn.started\", {});\n sentMessages.length = 0;\n\n input.value = \"keep going\";\n document.querySelector(\"#composer\").dispatchEvent(new SubmitEvent(\"submit\", { bubbles: true, cancelable: true }));\n\n const submitMsg = sentPayloads(sentMessages).find((p) => p.method === \"session.submit\");\n socket.onmessage({ data: JSON.stringify({ jsonrpc: \"2.0\", id: submitMsg.id, error: { code: -32603, message: \"fail\" } }) } as MessageEvent);\n\n await vi.waitFor(() => {\n expect(send.classList.contains(\"guidance-pending\")).toBe(false);\n });\n expect(input.value).toBe(\"keep going\");\n });\n\n it(\"clears input text when guidance submission succeeds\", async () => {\n const { sentMessages, socket } = setupOpenSocketWithHandle();\n const input = document.querySelector(\"#input\");\n const send = document.querySelector(\"#btn-send\");\n\n handleNotification(\"workspace.snapshot\", {\n active_thread_id: \"t1\",\n active_snapshot: { thread_id: \"t1\", nodes: [] },\n threads: [{ thread_id: \"t1\", title: \"Default\", workspace: \"\" }],\n workspace: \"\",\n provider: \"deepseek\",\n model: \"deepseek-chat\",\n profile_configured: true,\n });\n handleNotification(\"turn.started\", {});\n sentMessages.length = 0;\n\n input.value = \"keep going\";\n document.querySelector(\"#composer\").dispatchEvent(new SubmitEvent(\"submit\", { bubbles: true, cancelable: true }));\n\n const submitMsg = sentPayloads(sentMessages).find((p) => p.method === \"session.submit\");\n socket.onmessage({ data: JSON.stringify({ jsonrpc: \"2.0\", id: submitMsg.id, result: { ok: true } }) } as MessageEvent);\n\n await vi.waitFor(() => {\n expect(input.value).toBe(\"\");\n });\n expect(send.classList.contains(\"guidance-pending\")).toBe(false);\n });\n\n it(\"clears running state when a turn end notification arrives\", () => {\n const send = document.querySelector(\"#btn-send\");\n handleNotification(\"turn.started\", {});\n\n expect(send.classList.contains(\"running\")).toBe(true);\n\n handleNotification(\"turn.completed\", {});\n\n expect(send.classList.contains(\"running\")).toBe(false);\n expect(send.querySelector(\"svg.vx-icon\")).not.toBeNull();\n });\n\n it(\"renders a visible error when a turn fails\", () => {\n const send = document.querySelector(\"#btn-send\");\n handleNotification(\"turn.started\", {});\n\n handleNotification(\"turn.failed\", { message: \"LLM call failed: invalid API key\" });\n\n expect(send.classList.contains(\"running\")).toBe(false);\n expect(send.querySelector(\"svg.vx-icon\")).not.toBeNull();\n expect(document.querySelector(\"#transcript\").textContent).toContain(\"LLM call failed: invalid API key\");\n expect(document.querySelector(\".message-error\")).not.toBeNull();\n });\n\n it(\"does not open request dialog for status-only permission prompt notifications\", () => {\n const dialog = document.querySelector(\"#request-dialog\");\n const showModal = vi.spyOn(dialog, \"showModal\").mockImplementation(() => {});\n\n handleNotification(\"item.started\", {\n kind: \"prompt\",\n item_id: \"prompt-1\",\n data: {\n prompt_type: \"permission\",\n interactive: false,\n prompt: \"允许写文件?\",\n choices: [[\"Yes\", \"allow\", \"允许一次\"]],\n tools: [{ name: \"write\", pattern: \"/tmp/a.txt\", args: { path: \"/tmp/a.txt\" } }],\n },\n });\n\n expect(showModal).not.toHaveBeenCalled();\n expect(document.querySelector(\"#request-controls\").textContent).toBe(\"\");\n });\n\n it(\"opens request dialog for real permission ui requests\", () => {\n const dialog = document.querySelector(\"#request-dialog\");\n const showModal = vi.spyOn(dialog, \"showModal\").mockImplementation(() => {});\n\n handleNotification(\"ui.request\", {\n kind: \"permission\",\n request_id: \"perm_1\",\n thread_id: \"t2\",\n prompt: \"允许写文件?\",\n choices: [[\"Yes\", \"y\", \"允许一次\"]],\n tools: [{ name: \"write\", pattern: \"/tmp/a.txt\", args: { path: \"/tmp/a.txt\" } }],\n });\n\n expect(showModal).toHaveBeenCalled();\n expect(document.querySelector(\"#request-title\").textContent).toContain(\"允许写文件\");\n expect(document.querySelector(\"#request-controls\").textContent).toContain(\"允许一次\");\n });\n\n\n it(\"renders risk-aware permission request details\", () => {\n const dialog = document.querySelector(\"#request-dialog\");\n vi.spyOn(dialog, \"showModal\").mockImplementation(() => {});\n\n handleNotification(\"ui.request\", {\n kind: \"permission\",\n request_id: \"perm_risk_1\",\n thread_id: \"t2\",\n prompt: \"Allow tool use?\",\n choices: [[\"Do not run\", \"n\", \"This command is blocked\"]],\n tools: [\n {\n name: \"bash\",\n pattern: \"sudo true\",\n args: { command: \"sudo true\" },\n risk: {\n level: \"blocked\",\n tags: [\"privilege_escalation\"],\n reason: \"sudo is blocked\",\n tool_name: \"bash\",\n pattern: \"sudo true\",\n },\n allowed_scopes: [\"once\"],\n default_scope: \"once\",\n },\n ],\n });\n\n const details = document.querySelector(\"#request-details\");\n expect(details.textContent).toContain(\"bash\");\n expect(details.textContent).toContain(\"sudo true\");\n expect(details.textContent).toContain(\"blocked\");\n expect(details.textContent).toContain(\"privilege_escalation\");\n expect(details.textContent).toContain(\"sudo is blocked\");\n expect(details.textContent).not.toContain(\"Allowed scopes: once\");\n expect(details.textContent).not.toContain(\"Default scope: once\");\n expect(document.querySelector(\"#request-controls\").textContent).toContain(\"This command is blocked\");\n expect(document.querySelectorAll(\"#request-controls button\")).toHaveLength(1);\n expect(document.querySelector(\"#request-controls\").textContent).not.toContain(\"Allow this command\");\n });\n\n it(\"renders AI approval failure reason in permission details\", () => {\n const dialog = document.querySelector(\"#request-dialog\");\n vi.spyOn(dialog, \"showModal\").mockImplementation(() => {});\n\n handleNotification(\"ui.request\", {\n kind: \"permission\",\n request_id: \"perm_ai_1\",\n thread_id: \"t2\",\n prompt: \"Allow tool use?\",\n choices: [[\"Yes\", \"y\", \"Allow once\"]],\n tools: [\n {\n name: \"bash\",\n pattern: \"./build.sh\",\n args: { command: \"./build.sh\" },\n ai_approval_failure: \"AI approval failed: error\",\n },\n ],\n });\n\n expect(document.querySelector(\"#request-details\").textContent).toContain(\"AI approval failed: error\");\n });\n\n it(\"queues overlapping ui requests instead of replacing the active dialog\", () => {\n const sentMessages = setupOpenSocket();\n const dialog = document.querySelector(\"#request-dialog\");\n vi.spyOn(dialog, \"showModal\").mockImplementation(() => {\n dialog.setAttribute(\"open\", \"\");\n });\n vi.spyOn(dialog, \"close\").mockImplementation(() => {\n dialog.removeAttribute(\"open\");\n });\n\n handleNotification(\"ui.request\", {\n kind: \"choice\",\n request_id: \"read_1\",\n thread_id: \"t2\",\n prompt: \"Read file outside workspace? /tmp/a.txt\",\n choices: [[\"Yes\", \"allow\", \"Allow this read once\"]],\n });\n handleNotification(\"ui.request\", {\n kind: \"choice\",\n request_id: \"read_2\",\n thread_id: \"t2\",\n prompt: \"Read file outside workspace? /tmp/b.txt\",\n choices: [[\"Yes\", \"allow\", \"Allow this read once\"]],\n });\n\n expect(document.querySelector(\"#request-title\").textContent).toContain(\"/tmp/a.txt\");\n document.querySelector(\"#request-controls button\").click();\n\n expect(sentPayloads(sentMessages).find((payload) => payload.id === \"read_1\")).toMatchObject({\n id: \"read_1\",\n result: { value: \"allow\" },\n });\n expect(document.querySelector(\"#request-title\").textContent).toContain(\"/tmp/b.txt\");\n document.querySelector(\"#request-controls button\").click();\n\n expect(sentPayloads(sentMessages).find((payload) => payload.id === \"read_2\")).toMatchObject({\n id: \"read_2\",\n result: { value: \"allow\" },\n });\n });\n\n it(\"sends prompt item responses through session.respond\", () => {\n const sentMessages = setupOpenSocket();\n const dialog = document.querySelector(\"#request-dialog\");\n vi.spyOn(dialog, \"showModal\").mockImplementation(() => {});\n vi.spyOn(dialog, \"close\").mockImplementation(() => {});\n\n handleNotification(\"item.started\", {\n kind: \"prompt\",\n item_id: \"prompt-1\",\n data: {\n prompt_type: \"clarify\",\n clarify_id: \"cl_1\",\n thread_id: \"t2\",\n question: \"选哪个方案?\",\n options: [\"直接实现\", \"先写文档\"],\n },\n });\n document.querySelector(\"#request-controls button\").click();\n\n expect(sentPayloads(sentMessages).find((payload) => payload.method === \"session.respond\")).toMatchObject({\n method: \"session.respond\",\n params: { request_id: \"cl_1\", value: \"直接实现\", thread_id: \"t2\" },\n });\n });\n});\n\ndescribe(\"provider and model controls\", () => {\n it(\"does not show fake provider model before startup state arrives\", () => {\n const providerSelect = document.querySelector(\"#provider-select\");\n const modelSelect = document.querySelector(\"#model-select\");\n\n expect(providerSelect.value).toBe(\"\");\n expect(modelSelect.value).toBe(\"\");\n expect([...providerSelect.options].map((option) => option.value)).toContain(\"\");\n expect([...modelSelect.options].map((option) => option.value)).toEqual([\"\"]);\n expect(document.querySelector(\"#context-permission\")).toBeNull();\n expect(document.querySelector(\"#status-permission\")).toBeNull();\n expect(document.querySelector(\"#status-provider-model\").textContent).toBe(\"等待模型状态\");\n });\n\n it(\"renders provider and model selects from configured profiles\", async () => {\n const { sentMessages, socket } = setupOpenSocketWithHandle();\n const providerSelect = document.querySelector(\"#provider-select\");\n const modelSelect = document.querySelector(\"#model-select\");\n\n handleNotification(\"workspace.snapshot\", {\n active_thread_id: \"t1\",\n active_snapshot: { thread_id: \"t1\", nodes: [] },\n threads: [{ thread_id: \"t1\", title: \"Default\" }],\n workspace: \"/\",\n provider: \"\",\n model: \"\",\n });\n const request = sentPayloads(sentMessages).find((p) => p.method === \"settings.get\");\n socket.onmessage({\n data: JSON.stringify({\n jsonrpc: \"2.0\",\n id: request.id,\n result: {\n model: { provider: \"anthropic\", model: \"claude-sonnet-4-6\" },\n profiles: [\n { name: \"anthropic/claude-sonnet-4-6\", provider: \"anthropic\", model: \"claude-sonnet-4-6\", configured: true },\n ],\n },\n }),\n });\n\n await vi.waitFor(() => {\n expect(providerSelect.value).toBe(\"anthropic\");\n });\n expect(providerSelect).not.toBeNull();\n expect(modelSelect).not.toBeNull();\n expect([...providerSelect.options].map((option) => option.value)).toEqual([\"anthropic\"]);\n expect([...modelSelect.options].map((option) => option.value)).toEqual([\"claude-sonnet-4-6\"]);\n });\n\n it(\"changing provider refreshes configured model options without submitting\", async () => {\n const { sentMessages, socket } = setupOpenSocketWithHandle();\n const providerSelect = document.querySelector(\"#provider-select\");\n const modelSelect = document.querySelector(\"#model-select\");\n\n handleNotification(\"workspace.snapshot\", {\n active_thread_id: \"t1\",\n active_snapshot: { thread_id: \"t1\", nodes: [] },\n threads: [{ thread_id: \"t1\", title: \"Default\" }],\n workspace: \"/\",\n provider: \"\",\n model: \"\",\n });\n const request = sentPayloads(sentMessages).find((p) => p.method === \"settings.get\");\n socket.onmessage({\n data: JSON.stringify({\n jsonrpc: \"2.0\",\n id: request.id,\n result: {\n model: { provider: \"deepseek\", model: \"deepseek-v4-flash\" },\n profiles: [\n { name: \"deepseek/deepseek-v4-flash\", provider: \"deepseek\", model: \"deepseek-v4-flash\", configured: true },\n { name: \"anthropic/claude-sonnet-4-6\", provider: \"anthropic\", model: \"claude-sonnet-4-6\", configured: true },\n ],\n },\n }),\n });\n\n await vi.waitFor(() => {\n expect(providerSelect.value).toBe(\"deepseek\");\n });\n providerSelect.value = \"anthropic\";\n providerSelect.dispatchEvent(new Event(\"change\", { bubbles: true }));\n\n expect([...modelSelect.options].map((option) => option.value)).toEqual([\"claude-sonnet-4-6\"]);\n expect(sentPayloads(sentMessages).filter((payload) => payload.method === \"session.submit\")).toHaveLength(0);\n });\n\n it(\"changing model submits exactly one slash command\", async () => {\n const { sentMessages, socket } = setupOpenSocketWithHandle();\n const providerSelect = document.querySelector(\"#provider-select\");\n const modelSelect = document.querySelector(\"#model-select\");\n\n handleNotification(\"workspace.snapshot\", {\n active_thread_id: \"t1\",\n active_snapshot: { thread_id: \"t1\", nodes: [] },\n threads: [{ thread_id: \"t1\", title: \"Default\" }],\n workspace: \"/\",\n provider: \"\",\n model: \"\",\n });\n const request = sentPayloads(sentMessages).find((p) => p.method === \"settings.get\");\n socket.onmessage({\n data: JSON.stringify({\n jsonrpc: \"2.0\",\n id: request.id,\n result: {\n model: { provider: \"anthropic\", model: \"claude-sonnet-4-6\" },\n profiles: [\n { name: \"anthropic/claude-sonnet-4-6\", provider: \"anthropic\", model: \"claude-sonnet-4-6\", configured: true },\n { name: \"anthropic/claude-opus-4-1\", provider: \"anthropic\", model: \"claude-opus-4-1\", configured: true },\n ],\n },\n }),\n });\n\n await vi.waitFor(() => {\n expect(modelSelect.value).toBe(\"claude-sonnet-4-6\");\n });\n providerSelect.value = \"anthropic\";\n providerSelect.dispatchEvent(new Event(\"change\", { bubbles: true }));\n modelSelect.value = \"claude-opus-4-1\";\n modelSelect.dispatchEvent(new Event(\"change\", { bubbles: true }));\n\n await vi.waitFor(() => {\n expect(sentPayloads(sentMessages).filter((payload) => payload.method === \"session.submit\")).toHaveLength(1);\n });\n expect(sentPayloads(sentMessages).find((payload) => payload.method === \"session.submit\")).toMatchObject({\n method: \"session.submit\",\n params: { text: \"/model switch anthropic/claude-opus-4-1 --local\", thread_id: \"t1\" },\n });\n });\n\n it(\"startup.shown syncs provider model workspace and status panel\", () => {\n handleNotification(\"startup.shown\", {\n workspace: \"/Users/chikham/workspace/voidx\",\n provider: \"deepseek\",\n model: \"deepseek-reasoner\",\n profile_configured: false,\n });\n\n expect(document.querySelector(\"#provider-select\").value).toBe(\"deepseek\");\n expect(document.querySelector(\"#model-select\").value).toBe(\"deepseek-reasoner\");\n expect(document.querySelector(\"#status-provider-model\").textContent).toContain(\"deepseek/deepseek-reasoner\");\n expect(document.querySelector(\"#status-permission\")).toBeNull();\n });\n\n it(\"workspace.snapshot syncs startup default model status\", () => {\n handleNotification(\"workspace.snapshot\", {\n active_thread_id: \"t1\",\n active_snapshot: { thread_id: \"t1\", nodes: [] },\n threads: [{ thread_id: \"t1\", title: \"Default\" }],\n workspace: \"/Users/chikham/workspace/voidx\",\n provider: \"deepseek\",\n model: \"deepseek-chat\",\n profile_configured: true,\n });\n\n expect(document.querySelector(\"#provider-select\").value).toBe(\"deepseek\");\n expect(document.querySelector(\"#model-select\").value).toBe(\"deepseek-chat\");\n expect(document.querySelector(\"#status-provider-model\").textContent).toContain(\"deepseek/deepseek-chat\");\n expect(document.querySelector(\"#status-permission\")).toBeNull();\n });\n\n it(\"workspace.snapshot fetches settings when startup model is missing\", async () => {\n const { sentMessages, socket } = setupOpenSocketWithHandle();\n\n handleNotification(\"workspace.snapshot\", {\n active_thread_id: \"t1\",\n active_snapshot: { thread_id: \"t1\", nodes: [] },\n threads: [{ thread_id: \"t1\", title: \"Default\" }],\n workspace: \"/Users/chikham/workspace/voidx\",\n });\n\n const request = sentPayloads(sentMessages).find((p) => p.method === \"settings.get\");\n expect(request).toMatchObject({\n method: \"settings.get\",\n params: {},\n });\n\n socket.onmessage({\n data: JSON.stringify({\n jsonrpc: \"2.0\",\n id: request.id,\n result: {\n model: {\n provider: \"xunfei-coding-plan\",\n model: \"astron-code-latest\",\n },\n profiles: [\n {\n provider: \"xunfei-coding-plan\",\n model: \"astron-code-latest\",\n configured: true,\n },\n ],\n },\n }),\n });\n\n await vi.waitFor(() => {\n expect(document.querySelector(\"#status-provider-model\").textContent).toContain(\"xunfei-coding-plan/astron-code-latest\");\n });\n expect(document.querySelector(\"#provider-select\").value).toBe(\"xunfei-coding-plan\");\n expect(document.querySelector(\"#model-select\").value).toBe(\"astron-code-latest\");\n expect(document.querySelector(\"#status-permission\")).toBeNull();\n });\n\n it(\"workspace.snapshot fetches settings when startup model fields are empty\", async () => {\n const { sentMessages, socket } = setupOpenSocketWithHandle();\n\n handleNotification(\"workspace.snapshot\", {\n active_thread_id: \"t1\",\n active_snapshot: { thread_id: \"t1\", nodes: [] },\n threads: [{ thread_id: \"t1\", title: \"Default\" }],\n workspace: \"/\",\n provider: \"\",\n model: \"\",\n profile_configured: null,\n });\n\n const request = sentPayloads(sentMessages).find((p) => p.method === \"settings.get\");\n expect(request).toMatchObject({\n method: \"settings.get\",\n params: {},\n });\n\n socket.onmessage({\n data: JSON.stringify({\n jsonrpc: \"2.0\",\n id: request.id,\n result: {\n model: {\n provider: \"deepseek\",\n model: \"deepseek-v4-flash\",\n },\n profiles: [\n {\n provider: \"deepseek\",\n model: \"deepseek-v4-flash\",\n configured: true,\n },\n ],\n },\n }),\n });\n\n await vi.waitFor(() => {\n expect(document.querySelector(\"#status-provider-model\").textContent).toContain(\"deepseek/deepseek-v4-flash\");\n });\n expect(document.querySelector(\"#provider-select\").value).toBe(\"deepseek\");\n expect(document.querySelector(\"#model-select\").value).toBe(\"deepseek-v4-flash\");\n expect(document.querySelector(\"#status-permission\")).toBeNull();\n });\n\n it(\"model selectors only list configured profiles returned by settings\", async () => {\n const { sentMessages, socket } = setupOpenSocketWithHandle();\n\n handleNotification(\"workspace.snapshot\", {\n active_thread_id: \"t1\",\n active_snapshot: { thread_id: \"t1\", nodes: [] },\n threads: [{ thread_id: \"t1\", title: \"Default\" }],\n workspace: \"/\",\n provider: \"\",\n model: \"\",\n });\n\n const request = sentPayloads(sentMessages).find((p) => p.method === \"settings.get\");\n socket.onmessage({\n data: JSON.stringify({\n jsonrpc: \"2.0\",\n id: request.id,\n result: {\n model: {\n provider: \"deepseek\",\n model: \"deepseek-v4-flash\",\n },\n profiles: [\n {\n name: \"deepseek/deepseek-v4-flash\",\n provider: \"deepseek\",\n model: \"deepseek-v4-flash\",\n configured: true,\n },\n {\n name: \"xunfei-coding-plan/astron-code-latest\",\n provider: \"xunfei-coding-plan\",\n model: \"astron-code-latest\",\n configured: true,\n },\n {\n name: \"openai/gpt-5.5\",\n provider: \"openai\",\n model: \"gpt-5.5\",\n configured: false,\n },\n ],\n },\n }),\n });\n\n await vi.waitFor(() => {\n expect(document.querySelector(\"#provider-select\").value).toBe(\"deepseek\");\n });\n\n const providerOptions = [...document.querySelectorAll(\"#provider-select option\")].map((option) => option.value);\n expect(providerOptions).toEqual([\"deepseek\", \"xunfei-coding-plan\"]);\n expect(providerOptions).not.toContain(\"openai\");\n expect(providerOptions).not.toContain(\"anthropic\");\n expect(providerOptions).not.toContain(\"gemini\");\n expect(providerOptions).not.toContain(\"custom\");\n expect(providerOptions).not.toContain(\"\");\n\n const providerSelect = document.querySelector(\"#provider-select\");\n providerSelect.value = \"xunfei-coding-plan\";\n providerSelect.dispatchEvent(new Event(\"change\"));\n\n const modelOptions = [...document.querySelectorAll(\"#model-select option\")].map((option) => option.value);\n expect(modelOptions).toEqual([\"astron-code-latest\"]);\n });\n\n it(\"workspace.snapshot with current model still fetches full configured profile list\", async () => {\n const { sentMessages, socket } = setupOpenSocketWithHandle();\n\n handleNotification(\"workspace.snapshot\", {\n active_thread_id: \"t1\",\n active_snapshot: { thread_id: \"t1\", nodes: [] },\n threads: [{ thread_id: \"t1\", title: \"Default\" }],\n workspace: \"/\",\n provider: \"deepseek\",\n model: \"deepseek-v4-flash\",\n profile_configured: true,\n });\n\n const request = sentPayloads(sentMessages).find((p) => p.method === \"settings.get\");\n expect(request).toMatchObject({\n method: \"settings.get\",\n params: {},\n });\n\n socket.onmessage({\n data: JSON.stringify({\n jsonrpc: \"2.0\",\n id: request.id,\n result: {\n model: {\n provider: \"deepseek\",\n model: \"deepseek-v4-flash\",\n },\n profiles: [\n {\n name: \"xunfei-coding-plan/astron-code-latest\",\n provider: \"xunfei-coding-plan\",\n model: \"astron-code-latest\",\n configured: true,\n },\n {\n name: \"aixhan/gpt-5.5\",\n provider: \"aixhan\",\n model: \"gpt-5.5\",\n configured: true,\n },\n {\n name: \"deepseek/deepseek-v4-flash\",\n provider: \"deepseek\",\n model: \"deepseek-v4-flash\",\n configured: true,\n },\n ],\n },\n }),\n });\n\n await vi.waitFor(() => {\n const providerOptions = [...document.querySelectorAll(\"#provider-select option\")].map((option) => option.value);\n expect(providerOptions).toEqual([\"xunfei-coding-plan\", \"aixhan\", \"deepseek\"]);\n });\n expect(document.querySelector(\"#provider-select\").value).toBe(\"deepseek\");\n expect([...document.querySelectorAll(\"#provider-select option\")].map((option) => option.value)).not.toContain(\"\");\n });\n});\n\ndescribe(\"bottom panel\", () => {\n it(\"contains Todo Terminal Diff and Status tabs\", () => {\n const labels = [...document.querySelectorAll(\".vx-dock-tab\")].map((tab) => tab.textContent.trim());\n expect(labels).toEqual([\"Todo\", \"Terminal\", \"Diff\", \"Status\"]);\n });\n\n it(\"does not show duplicate status rows below the composer\", () => {\n const contextRow = document.querySelector(\"#context-row\");\n const dockStrip = document.querySelector(\"#dock-strip\");\n\n expect(contextRow.hidden).toBe(true);\n expect(dockStrip.hidden).toBe(true);\n });\n\n it(\"collapses to a status strip and preserves active tab\", () => {\n const dock = document.querySelector(\"#dock\");\n dock.classList.remove(\"collapsed\");\n document.querySelector(\"#dock-strip\").hidden = true;\n\n switchTab(\"status\");\n toggleDock();\n\n expect(dock.classList.contains(\"collapsed\")).toBe(true);\n expect(document.querySelector(\"#dock-strip\").hidden).toBe(true);\n\n toggleDock();\n expect(dock.classList.contains(\"collapsed\")).toBe(false);\n expect(getActiveTab()).toBe(\"status\");\n expect(document.querySelector('.vx-dock-pane[data-pane=\"status\"]').hidden).toBe(false);\n });\n});\n\n\nit(\"renders running-turn guidance exactly once from the backend message event\", async () => {\n const { sentMessages, socket } = setupOpenSocketWithHandle();\n const input = document.querySelector(\"#input\");\n\n handleNotification(\"workspace.snapshot\", {\n active_thread_id: \"t1\",\n active_snapshot: { thread_id: \"t1\", nodes: [] },\n threads: [{ thread_id: \"t1\", title: \"Default\", workspace: \"\" }],\n workspace: \"\",\n provider: \"deepseek\",\n model: \"deepseek-chat\",\n profile_configured: true,\n });\n handleNotification(\"turn.started\", {});\n sentMessages.length = 0;\n\n input.value = \"keep going\";\n document.querySelector(\"#composer\").dispatchEvent(\n new SubmitEvent(\"submit\", { bubbles: true, cancelable: true }),\n );\n\n expect(document.querySelectorAll(\".message-guidance\")).toHaveLength(0);\n\n const submitMsg = sentPayloads(sentMessages).find((p) => p.method === \"session.submit\");\n socket.onmessage({\n data: JSON.stringify({ jsonrpc: \"2.0\", id: submitMsg.id, result: { ok: true } }),\n } as MessageEvent);\n await vi.waitFor(() => expect(input.value).toBe(\"\"));\n expect(document.querySelectorAll(\".message-guidance\")).toHaveLength(0);\n\n handleNotification(\"item.started\", {\n kind: \"message\",\n item_id: \"guidance-1\",\n data: { style: \"guidance\", text: \"keep going\" },\n });\n\n const messages = document.querySelectorAll(\".message-guidance\");\n expect(messages).toHaveLength(1);\n expect(messages[0].textContent).toContain(\"keep going\");\n});\n\n\ndescribe(\"AI approval permission mode\", () => {\n it(\"shows AI approval in the permission dropdown\", async () => {\n const dropdown = document.createElement(\"div\");\n dropdown.id = \"permission-dropdown\";\n document.body.append(dropdown);\n populatePermissionDropdown();\n expect(dropdown.textContent).toContain(\"AI 审批\");\n expect(dropdown.textContent).toContain(\"受限工具参数\");\n });\n});\n"}}}}