diff --git a/docs/usage.md b/docs/usage.md index 5de215d0..b280626b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -38,6 +38,24 @@ false until that matrix is signed. ## Threads, Runs, and advanced diagnostics / Thread、Run 与高级诊断 +In Web and Desktop, a newly created task opens automatically only while its +original waiting page is still current. After navigating elsewhere, use the +creation notice to open it; its first message continues with the original +request identity. Resuming a paused task also keeps its pending result, retry +identity, and refresh bound to that task and Run, without changing another task's +draft or controls. + +Web 和 Desktop 创建任务后,仅在仍停留于原等待页时自动打开。切换页面后,可通过 +“打开已创建的对话”提示主动进入;首条消息仍按原请求继续交接。解除暂停的等待、 +错误与重试归属原任务及 Run,不影响当前任务的草稿和操作状态。 + +Both connection paths preserve the enabled batch delivery and host-validation +capabilities together with their existing permission requirements. Read-only +connections can inspect pending approvals and the API's redacted previews; +approval, denial, and decision recovery still require approval control authority. +两种连接入口均保留已启用的批次交付能力及原有权限组合。只读连接可查看待审批条目 +与接口允许展示的脱敏预览;批准、拒绝和恢复决定仍需审批控制权限。 + ```powershell cyberagent run create "review this workspace" --workspace demo --profile review --surface code --phase plan cyberagent run create "explain this code" --profile learn --max-turns 40 --max-tokens 20000 --timeout 20m diff --git a/web/src/components/connection-gate.test.tsx b/web/src/components/connection-gate.test.tsx index c039ecab..2f8112a4 100644 --- a/web/src/components/connection-gate.test.tsx +++ b/web/src/components/connection-gate.test.tsx @@ -1,7 +1,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from "@testing-library/react"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { useConnectionStore } from "../state/connection"; +import { createV2Client } from "../v2/client-session"; import { ConnectionGate } from "./connection-gate"; vi.mock("../lib/locale", () => ({ @@ -15,8 +16,12 @@ describe("ConnectionGate", () => { delete window.go; }); - afterEach(() => { + afterEach(async () => { + cleanup(); + const { useConnectionStore: currentStore } = await import("../state/connection"); + currentStore.getState().disconnect(); vi.unstubAllGlobals(); + vi.unstubAllEnvs(); }); it("validates the token through health and does not render it as text", async () => { @@ -48,6 +53,10 @@ describe("ConnectionGate", () => { expect(useConnectionStore.getState().fileEditProposalEnabled).toBe(true); expect(useConnectionStore.getState().workspaceImportEnabled).toBe(true); expect(useConnectionStore.getState().threadExecutionReadEnabled).toBe(true); + expect(useConnectionStore.getState().apiBaseURL).toBe("/api/v1"); + const client = createV2Client(useConnectionStore.getState()); + expect(client.hasBatchDeliveryControl).toBe(true); + expect(client.hasBatchDeliveryHostValidation).toBe(true); }); it("retains the advertised execution read route when connecting without a control token", async () => { @@ -65,9 +74,37 @@ describe("ConnectionGate", () => { expect(useConnectionStore.getState().threadExecutionReadEnabled).toBe(true); expect(useConnectionStore.getState().runExecutionEnabled).toBe(false); expect(useConnectionStore.getState().threadControlEnabled).toBe(false); + expect(createV2Client(useConnectionStore.getState()).hasBatchDeliveryControl).toBe(false); + expect(createV2Client(useConnectionStore.getState()).hasBatchDeliveryHostValidation).toBe(false); }); - it("auto-connects a closed-authority Desktop bootstrap without rendering its token", async () => { + it("rejects contradictory Web batch host capabilities before connecting", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({ + version: "api.v1", request_id: "req-health", + data: { status: "ok", api_version: "api.v1", app_version: "test", schema_version: 129 }, + }), { status: 200 })).mockResolvedValueOnce(new Response(JSON.stringify({ + version: "api.v1", request_id: "req-capabilities", + data: { ...runtimeCapabilities(), batch_delivery_control_enabled: false }, + }), { status: 200 }))); + const user = userEvent.setup(); + render(); + await user.type(screen.getByLabelText("只读访问令牌"), "read-token-fixture"); + await user.type(screen.getByLabelText(/控制访问令牌/), "control-token-fixture"); + await user.click(screen.getByRole("button", { name: "连接" })); + expect(await screen.findByRole("alert")).toHaveTextContent("capability response is invalid"); + expect(useConnectionStore.getState().token).toBe(""); + expect(useConnectionStore.getState().batchDeliveryControlEnabled).toBe(false); + expect(useConnectionStore.getState().batchDeliveryHostValidationEnabled).toBe(false); + }); + + it.each([false, true])("carries Desktop batch authority through bootstrap without exposing its token: %s", async (control) => { + vi.resetModules(); + const { ConnectionGate } = await import("./connection-gate"); + const { useConnectionStore } = await import("../state/connection"); + const { createV2Client } = await import("../v2/client-session"); + // Desktop's validated address must survive V2 client construction even if + // a Web build default points elsewhere. + vi.stubEnv("VITE_API_BASE_URL", "https://other-origin.example/api/v1"); const bootstrap = vi.fn().mockResolvedValue({ protocol_version: "desktop_connection_bootstrap.v1", agent_code_tools_enabled: true, @@ -77,14 +114,14 @@ describe("ConnectionGate", () => { app_version: "v0.1.0", ui_digest: "a".repeat(64), read_token: "desktop-read-token-0123456789abcdef", - control_token: "", + control_token: control ? "desktop-control-token-0123456789abcdef" : "", control_enabled: false, - execution_permission_control_enabled: false, + execution_permission_control_enabled: control, browser_cdp_permission_control_enabled: false, full_cdp_debug_enabled: false, full_cdp_session_control_enabled: false, - operator_approval_enabled: false, - danger_full_access_enabled: false, + operator_approval_enabled: control, + danger_full_access_enabled: control, workspace_sandbox_enabled: false, command_runtime_enabled: false, @@ -112,7 +149,7 @@ describe("ConnectionGate", () => { run_wake_worker_enabled: false, scheduled_job_control_enabled: false, scheduled_job_worker_enabled: false, - read_only_default: true, + read_only_default: !control, process_execution_enabled: false, shell_execution_enabled: false, docker_execution_enabled: false, @@ -124,8 +161,8 @@ describe("ConnectionGate", () => { workspace_checkpoint_control_enabled: false, git_advanced_control_enabled: false, github_review_control_enabled: false, - batch_delivery_control_enabled: false, - batch_delivery_host_validation_enabled: false, + batch_delivery_control_enabled: control, + batch_delivery_host_validation_enabled: control, user_terminal_enabled: false, agent_terminal_input_default: false, workspace_open_enabled: false, @@ -151,10 +188,16 @@ describe("ConnectionGate", () => { render(); await waitFor(() => expect(useConnectionStore.getState().token) .toBe("desktop-read-token-0123456789abcdef")); - expect(useConnectionStore.getState().controlToken).toBe(""); + expect(useConnectionStore.getState().controlToken) + .toBe(control ? "desktop-control-token-0123456789abcdef" : ""); expect(useConnectionStore.getState().workspaceImportEnabled).toBe(false); expect(useConnectionStore.getState().threadExecutionReadEnabled).toBe(false); + const client = createV2Client(useConnectionStore.getState()); + expect(client.baseURL).toBe("/api/v1"); + expect(client.hasBatchDeliveryControl).toBe(control); + expect(client.hasBatchDeliveryHostValidation).toBe(control); expect(screen.queryByText("desktop-read-token-0123456789abcdef")).not.toBeInTheDocument(); + expect(screen.queryByText("desktop-control-token-0123456789abcdef")).not.toBeInTheDocument(); expect(bootstrap).toHaveBeenCalledTimes(1); }); }); @@ -200,7 +243,7 @@ function runtimeCapabilities() { git_advanced_control_enabled: true, github_review_control_enabled: true, batch_delivery_control_enabled: true, - batch_delivery_host_validation_enabled: false, + batch_delivery_host_validation_enabled: true, shell_execution_enabled: true, docker_execution_enabled: false, wake_worker: { protocol_version: "run_wake_worker_health.v1", enabled: false, state: "disabled", active: false, poll_interval_ms: 0, concurrency: 1, diff --git a/web/src/components/connection-gate.tsx b/web/src/components/connection-gate.tsx index 0be1eef7..8ce0d922 100644 --- a/web/src/components/connection-gate.tsx +++ b/web/src/components/connection-gate.tsx @@ -85,7 +85,7 @@ export function ConnectionGate() { dockerExecutionEnabled: bootstrap.docker_execution_enabled, agentCodeToolsEnabled: bootstrap.agent_code_tools_enabled, codeIntelEnabled: bootstrap.code_intel_enabled, - }); + }, client.baseURL); }).catch((caught: unknown) => { if (active) { setError(desktopErrorMessage(caught)); @@ -114,7 +114,8 @@ export function ConnectionGate() { client.health(), client.runtimeCapabilities(), ]); queryClient.clear(); - connect(candidate, health, controlToken.trim(), clientCapabilitiesFromRuntime(capabilities)); + connect(candidate, health, controlToken.trim(), clientCapabilitiesFromRuntime(capabilities), + client.baseURL); setToken(""); setControlToken(""); } catch (caught) { diff --git a/web/src/lib/desktop-bridge.test.ts b/web/src/lib/desktop-bridge.test.ts index f4755b5e..c81c14f6 100644 --- a/web/src/lib/desktop-bridge.test.ts +++ b/web/src/lib/desktop-bridge.test.ts @@ -362,6 +362,26 @@ describe("desktop native bridge", () => { await expect(module.loadDesktopBootstrap()).resolves.toEqual(dockerEnabled); }); + it.each([ + "control_token", "batch_delivery_control_enabled", "execution_permission_control_enabled", + "operator_approval_enabled", "danger_full_access_enabled", + ])("rejects batch host validation without its %s gate", async (missing) => { + installBridge({ Bootstrap: vi.fn().mockResolvedValue({ + ...bootstrap, + control_token: "control-token-0123456789abcdefghijkl", + execution_permission_control_enabled: true, + operator_approval_enabled: true, + danger_full_access_enabled: true, + batch_delivery_control_enabled: true, + batch_delivery_host_validation_enabled: true, + read_only_default: false, + [missing]: missing === "control_token" ? "" : false, + }) }); + const module = await import("./desktop-bridge"); + await expect(module.loadDesktopBootstrap()).rejects.toThrow("bootstrap was rejected"); + expect(module.desktopRuntimeActive()).toBe(false); + }); + it("accepts Advanced Git only with permission, approval, and checkpoint gates", async () => { const enabled = { ...bootstrap, diff --git a/web/src/state/connection.test.ts b/web/src/state/connection.test.ts index 6b5fad42..b58ac0c3 100644 --- a/web/src/state/connection.test.ts +++ b/web/src/state/connection.test.ts @@ -22,11 +22,12 @@ describe("connection store", () => { commandRuntimeProtocolAvailable: true, commandRuntimeAdapterInstalled: true, commandRuntimeAdapterReady: true, - }); + }, "/api/v1"); useConnectionStore.getState().selectRun("run-1"); expect(useConnectionStore.getState().token).toBe("ephemeral-token"); expect(useConnectionStore.getState().controlToken).toBe("ephemeral-control-token"); + expect(useConnectionStore.getState().apiBaseURL).toBe("/api/v1"); expect(useConnectionStore.getState().runControlEnabled).toBe(true); expect(useConnectionStore.getState().runCreationEnabled).toBe(true); expect(useConnectionStore.getState().sessionMessageEnabled).toBe(true); @@ -48,6 +49,7 @@ describe("connection store", () => { useConnectionStore.getState().disconnect(); expect(useConnectionStore.getState().token).toBe(""); expect(useConnectionStore.getState().controlToken).toBe(""); + expect(useConnectionStore.getState().apiBaseURL).toBeUndefined(); expect(useConnectionStore.getState().runControlEnabled).toBe(false); expect(useConnectionStore.getState().runCreationEnabled).toBe(false); expect(useConnectionStore.getState().sessionMessageEnabled).toBe(false); @@ -97,4 +99,30 @@ describe("connection store", () => { connect("read", health, "control", { runExecutionEnabled: true }); expect(createV2Client(useConnectionStore.getState()).hasThreadExecutionRead).toBe(false); }); + + it("requires explicit batch capabilities and clears them on reconnect and disconnect", () => { + const connect = useConnectionStore.getState().connect; + const capabilities = { + batchDeliveryControlEnabled: true, batchDeliveryHostValidationEnabled: true, + executionPermissionControlEnabled: true, operatorApprovalEnabled: true, + dangerFullAccessEnabled: true, + }; + connect("read", health, "control", capabilities); + expect(useConnectionStore.getState().batchDeliveryControlEnabled).toBe(true); + expect(useConnectionStore.getState().batchDeliveryHostValidationEnabled).toBe(true); + expect(createV2Client(useConnectionStore.getState()).hasBatchDeliveryHostValidation).toBe(true); + expect(localStorage.length).toBe(0); + expect(sessionStorage.length).toBe(0); + + connect("read", health, "control"); + expect(useConnectionStore.getState().batchDeliveryControlEnabled).toBe(false); + expect(useConnectionStore.getState().batchDeliveryHostValidationEnabled).toBe(false); + connect("read", health, "", capabilities); + expect(useConnectionStore.getState().batchDeliveryControlEnabled).toBe(false); + expect(useConnectionStore.getState().batchDeliveryHostValidationEnabled).toBe(false); + connect("read", health, "control", capabilities); + useConnectionStore.getState().disconnect(); + expect(useConnectionStore.getState().batchDeliveryControlEnabled).toBe(false); + expect(useConnectionStore.getState().batchDeliveryHostValidationEnabled).toBe(false); + }); }); diff --git a/web/src/state/connection.ts b/web/src/state/connection.ts index 145b2774..aee4acc6 100644 --- a/web/src/state/connection.ts +++ b/web/src/state/connection.ts @@ -6,6 +6,7 @@ type ResourceKind = "thread" | "run" | "session"; interface ConnectionState { health: HealthView | null; + apiBaseURL: string | undefined; resourceKind: ResourceKind; selectedRunID: string; selectedSessionID: string; @@ -53,11 +54,13 @@ interface ConnectionState { gitAdvancedControlEnabled: boolean; githubReviewControlEnabled: boolean; workspaceCheckpointControlEnabled: boolean; + batchDeliveryControlEnabled: boolean; + batchDeliveryHostValidationEnabled: boolean; dockerExecutionEnabled: boolean; agentCodeToolsEnabled: boolean; codeIntelEnabled: boolean; connect: (token: string, health: HealthView, controlToken?: string, - capabilities?: ClientCapabilities) => void; + capabilities?: ClientCapabilities, apiBaseURL?: string) => void; disconnect: () => void; selectRun: (runID: string) => void; selectSession: (sessionID: string) => void; @@ -76,6 +79,7 @@ const initialSelection = { export const useConnectionStore = create((set) => ({ ...initialSelection, health: null, + apiBaseURL: undefined, token: "", controlToken: "", runControlEnabled: false, @@ -122,12 +126,14 @@ export const useConnectionStore = create((set) => ({ gitAdvancedControlEnabled: false, githubReviewControlEnabled: false, workspaceCheckpointControlEnabled: false, + batchDeliveryControlEnabled: false, + batchDeliveryHostValidationEnabled: false, dockerExecutionEnabled: false, agentCodeToolsEnabled: false, codeIntelEnabled: false, - connect: (token, health, controlToken = "", capabilities = {}) => { + connect: (token, health, controlToken = "", capabilities = {}, apiBaseURL) => { const present = controlToken.trim() !== ""; - set({ token, health, controlToken, + set({ token, health, controlToken, apiBaseURL, runControlEnabled: present && (capabilities.runControlEnabled ?? true), workspaceImportEnabled: present && capabilities.workspaceImportEnabled === true, executionPermissionControlEnabled: present && @@ -196,6 +202,9 @@ export const useConnectionStore = create((set) => ({ (capabilities.githubReviewControlEnabled ?? false), workspaceCheckpointControlEnabled: present && (capabilities.workspaceCheckpointControlEnabled ?? false), + batchDeliveryControlEnabled: present && capabilities.batchDeliveryControlEnabled === true, + batchDeliveryHostValidationEnabled: present && + capabilities.batchDeliveryHostValidationEnabled === true, dockerExecutionEnabled: present && (capabilities.dockerExecutionEnabled ?? false), agentCodeToolsEnabled: present && @@ -203,7 +212,7 @@ export const useConnectionStore = create((set) => ({ codeIntelEnabled: capabilities.codeIntelEnabled ?? false, }); }, - disconnect: () => set({ token: "", controlToken: "", health: null, + disconnect: () => set({ token: "", controlToken: "", health: null, apiBaseURL: undefined, runControlEnabled: false, runCreationEnabled: false, workspaceImportEnabled: false, standardCodePresetEnabled: false, sessionMessageEnabled: false, @@ -233,6 +242,8 @@ export const useConnectionStore = create((set) => ({ gitAdvancedControlEnabled: false, githubReviewControlEnabled: false, workspaceCheckpointControlEnabled: false, + batchDeliveryControlEnabled: false, + batchDeliveryHostValidationEnabled: false, dockerExecutionEnabled: false, agentCodeToolsEnabled: false, codeIntelEnabled: false, diff --git a/web/src/v2/app.test.tsx b/web/src/v2/app.test.tsx index 14a5e572..f34a4393 100644 --- a/web/src/v2/app.test.tsx +++ b/web/src/v2/app.test.tsx @@ -72,6 +72,7 @@ afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); window.history.replaceState({}, "", "/"); + window.localStorage.clear(); }); function navigationClient() { @@ -933,6 +934,10 @@ describe("V2Workbench first turn", () => { await user.selectOptions(screen.getByRole("combobox", { name: "选择工作区" }), otherWorkspace.id); await user.type(screen.getByRole("textbox", { name: "开始新对话" }), "另一项目的独立草稿"); await act(async () => { creation.resolve({ thread: createdThread } as Awaited>); }); + expect(window.location.hash).toBe("#/new"); + expect(screen.getByRole("textbox", { name: "开始新对话" })).toHaveValue("另一项目的独立草稿"); + expect(screen.getByRole("button", { name: "发送消息" })).toBeEnabled(); + await user.click(await screen.findByRole("button", { name: "打开已创建的对话" })); expect(await screen.findByRole("textbox", { name: "任务草稿 fixture" })).toHaveValue("首条提交"); expect(queryClient.getQueryData(v2FileReferenceKey(workspace.id, createdThread.id))).toEqual(files); expect(queryClient.getQueryData(v2FileReferenceKey(workspace.id, ""))).toEqual([lateFile]); @@ -1030,9 +1035,9 @@ describe("V2Workbench first turn", () => { await act(async () => { secondCreation.reject(new Error("B creation response lost")); }); expect(await screen.findByRole("alert")).toHaveTextContent("B creation response lost"); await act(async () => { firstCreation.resolve({ thread: createdThread } as Awaited>); }); - expect(await screen.findByTestId("v2-conversation")).toHaveTextContent(createdThread.id); - await user.click(screen.getByRole("button", { name: "新对话" })); - await user.selectOptions(screen.getByRole("combobox", { name: "选择工作区" }), otherWorkspace.id); + expect(window.location.hash).toBe("#/new"); + expect(screen.getByRole("alert")).toHaveTextContent("B creation response lost"); + expect(screen.getByRole("button", { name: "打开已创建的对话" })).toBeInTheDocument(); expect(screen.getByRole("textbox", { name: "开始新对话" })).toHaveValue("B项目请求"); await user.click(screen.getByRole("button", { name: "发送消息" })); expect(await screen.findByTestId("v2-conversation")).toHaveTextContent(otherThread.id); @@ -1041,4 +1046,174 @@ describe("V2Workbench first turn", () => { expect(createThread.mock.calls[0]![1]).not.toBe(createThread.mock.calls[1]![1]); expect(client.submitThreadTurn).toHaveBeenCalledTimes(2); }); + + it.each(["created", "creation failed", "turn failed"] as const)( + "keeps task B and its draft when task A finishes in the background: %s", async (outcome) => { + window.history.replaceState({}, "", "#/new"); + const otherThread = { ...createdThread, id: "thread-existing-b", title: "Existing B" }; + const creation = deferred>>(); + const submitThreadTurn = vi.fn().mockImplementation(async (_threadID: string) => { + if (outcome === "turn failed") throw new APIRequestError("CONFLICT", "A execution failed", 409, + "failed-turn", undefined, undefined, true); + return { accepted: true }; + }); + const client = { hasThreadControl: true, + getPage: vi.fn(async (path: string) => ({ items: path === "/workspaces" ? [workspace] : [otherThread], + page: { limit: 100 }, requestID: path })), + createThread: vi.fn(() => creation.promise), submitThreadTurn, + availableModelRoutes: readyModelCatalog(), + } as unknown as APIClient; + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + render(); + const user = userEvent.setup(); + await user.type(await screen.findByRole("textbox", { name: "开始新对话" }), "A 的首条消息"); + await user.click(screen.getByRole("button", { name: "发送消息" })); + await waitFor(() => expect(client.createThread).toHaveBeenCalledTimes(1)); + await user.click(screen.getByRole("button", { name: "Existing B" })); + await user.type(screen.getByRole("textbox", { name: "任务草稿 fixture" }), "B 的独立草稿"); + await act(async () => { + if (outcome === "creation failed") creation.reject(new Error("A creation response lost")); + else creation.resolve({ thread: createdThread } as Awaited>); + }); + await waitFor(() => expect(queryClient.isMutating()).toBe(0)); + expect(window.location.hash).toBe(`#/threads/${otherThread.id}`); + expect(screen.getByTestId("v2-conversation")).toHaveTextContent(otherThread.id); + expect(screen.getByRole("textbox", { name: "任务草稿 fixture" })).toHaveValue("B 的独立草稿"); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + if (outcome === "creation failed") { + expect(submitThreadTurn).not.toHaveBeenCalled(); + expect(screen.queryByRole("button", { name: "打开已创建的对话" })).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "新对话" })); + expect(screen.getByRole("textbox", { name: "开始新对话" })).toHaveValue("A 的首条消息"); + } else { + expect(submitThreadTurn).toHaveBeenCalledTimes(1); + expect(submitThreadTurn.mock.calls[0]?.[0]).toBe(createdThread.id); + await user.click(screen.getByRole("button", { name: "打开已创建的对话" })); + expect(window.location.hash).toBe(`#/threads/${createdThread.id}`); + expect(screen.getByRole("textbox", { name: "任务草稿 fixture" })).toHaveValue(""); + expect(screen.queryByRole("button", { name: "打开已创建的对话" })).not.toBeInTheDocument(); + const mutations = queryClient.getMutationCache().findAll({ mutationKey: ["v2", "submit-turn"] }); + if (outcome === "turn failed") { + expect(mutations).toHaveLength(1); + expect(mutations[0]?.state).toMatchObject({ variables: { threadID: createdThread.id }, status: "error" }); + } else expect(mutations).toHaveLength(0); + } + }); + + it("offers an explicit open after leaving and returning to the same new-task page", async () => { + const creation = deferred>>(); + const client = { hasThreadControl: true, + getPage: vi.fn(async (path: string) => ({ items: path === "/workspaces" ? [workspace] : [], + page: { limit: 100 }, requestID: path })), + createThread: vi.fn(() => creation.promise), submitThreadTurn: vi.fn().mockResolvedValue({ accepted: true }), + availableModelRoutes: readyModelCatalog(), + } as unknown as APIClient; + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + render(); + const user = userEvent.setup(); + await user.type(await screen.findByRole("textbox", { name: "开始新对话" }), "原创建请求"); + await user.click(screen.getByRole("button", { name: "发送消息" })); + await waitFor(() => expect(client.createThread).toHaveBeenCalledTimes(1)); + await user.click(screen.getByRole("button", { name: "接入模型" })); + await user.click(screen.getByRole("button", { name: "返回应用" })); + fireEvent.change(screen.getByRole("textbox", { name: "开始新对话" }), { target: { value: "返回后正在写的新需求" } }); + await act(async () => { creation.resolve({ thread: createdThread } as Awaited>); }); + expect(window.location.hash).toBe("#/new"); + expect(screen.getByRole("textbox", { name: "开始新对话" })).toHaveValue("返回后正在写的新需求"); + await user.click(screen.getByRole("button", { name: "打开已创建的对话" })); + expect(screen.getByTestId("v2-conversation")).toHaveTextContent(createdThread.id); + }); + + it.each(["created", "response lost"] as const)( + "retains the durable creation handoff after navigation: %s", async (outcome) => { + window.history.replaceState({}, "", "#/new"); + useConnectionStore.getState().setHealth({ status: "ok", api_version: "api.v1", + app_version: "fixture", schema_version: 157, data_store_id: `ds1_${"c".repeat(64)}` }); + const otherThread = { ...createdThread, id: "thread-durable-b", title: "Durable B" }; + const creation = deferred>>(); + const submission = deferred>>(); + const inspectThreadCreationRequest = vi.fn().mockResolvedValue({ kind: "creation", + workspace_id: workspace.id, state: "not_received", settled: false }); + const client = { baseURL: "/api/v1", hasThreadControl: true, + getPage: vi.fn(async (path: string) => ({ items: path === "/workspaces" ? [workspace] : [otherThread], + page: { limit: 100 }, requestID: path })), + get: vi.fn().mockResolvedValue({ thread: createdThread }), + createThread: vi.fn(() => creation.promise), submitThreadTurn: vi.fn(() => submission.promise), + inspectThreadCreationRequest, availableModelRoutes: readyModelCatalog(), + } as unknown as APIClient; + render( + + ); + const savedValues = () => Object.values(window.localStorage).map((value: string) => JSON.parse(value).value); + const user = userEvent.setup(); + await user.type(await screen.findByRole("textbox", { name: "开始新对话" }), "持久保存的 A 请求"); + await user.click(screen.getByRole("button", { name: "发送消息" })); + await waitFor(() => expect(client.createThread).toHaveBeenCalledTimes(1)); + const intent = savedValues().find((value) => value?.version === "v2_creation_recovery.v1"); + expect(intent).toMatchObject({ content: "持久保存的 A 请求", request: { workspace_id: workspace.id } }); + await user.click(screen.getByRole("button", { name: "Durable B" })); + await user.type(screen.getByRole("textbox", { name: "任务草稿 fixture" }), "B 的当前草稿"); + await act(async () => { + if (outcome === "response lost") creation.reject(new Error("creation response lost")); + else creation.resolve({ thread: createdThread } as Awaited>); + }); + expect(window.location.hash).toBe(`#/threads/${otherThread.id}`); + expect(screen.getByRole("textbox", { name: "任务草稿 fixture" })).toHaveValue("B 的当前草稿"); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + if (outcome === "created") { + await waitFor(() => expect(client.submitThreadTurn).toHaveBeenCalledTimes(1)); + expect(savedValues().find((value) => value?.operationKey === `v2-thread-create-turn-${intent.operationID}`)) + .toMatchObject({ threadID: createdThread.id, workspaceID: workspace.id, content: intent.content }); + expect(savedValues().filter((value) => value?.version === "v2_creation_recovery.v1")).toEqual([]); + await act(async () => { submission.resolve({ accepted: true } as unknown as Awaited>); }); + await waitFor(() => expect(savedValues().find((value) => value?.operationKey === `v2-thread-create-turn-${intent.operationID}`)).toBeUndefined()); + expect(screen.getByRole("textbox", { name: "任务草稿 fixture" })).toHaveValue("B 的当前草稿"); + await user.click(screen.getByRole("button", { name: "打开已创建的对话" })); + } else { + expect(client.submitThreadTurn).not.toHaveBeenCalled(); + inspectThreadCreationRequest.mockResolvedValue({ kind: "creation", workspace_id: workspace.id, + state: "completed", settled: true, thread_id: createdThread.id, run_id: createdThread.last_run_id, + session_id: "session-created", request_fingerprint: "d".repeat(64) }); + await user.click(screen.getByRole("button", { name: "新对话" })); + expect(screen.getByRole("textbox", { name: "开始新对话" })).toHaveValue("持久保存的 A 请求"); + await user.click(await screen.findByRole("button", { name: "打开原对话" })); + expect(client.submitThreadTurn).not.toHaveBeenCalled(); + } + expect(window.location.hash).toBe(`#/threads/${createdThread.id}`); + expect(client.createThread).toHaveBeenCalledTimes(1); + }); + + it.each(["creation failed", "turn failed"] as const)( + "preserves a newer creation error after switching projects and returning: %s", async (outcome) => { + const otherWorkspace = { ...workspace, id: "workspace-round-trip", name: "Other project" }; + const creation = deferred>>(); + const createThread = vi.fn().mockImplementationOnce(() => creation.promise) + .mockRejectedValueOnce(new Error("new request failed")); + const client = { hasThreadControl: true, createThread, + getPage: vi.fn(async (path: string) => ({ items: path === "/workspaces" ? [workspace, otherWorkspace] : [], + page: { limit: 100 }, requestID: path })), + submitThreadTurn: vi.fn().mockRejectedValue(new APIRequestError("old turn failed", "CONFLICT", 409, + "failed-turn", undefined, undefined, true)), availableModelRoutes: readyModelCatalog(), + } as unknown as APIClient; + const queries = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + render(); + const user = userEvent.setup(); + await user.type(await screen.findByRole("textbox", { name: "开始新对话" }), "旧创建请求"); + await user.click(screen.getByRole("button", { name: "发送消息" })); + await waitFor(() => expect(createThread).toHaveBeenCalledTimes(1)); + await user.selectOptions(screen.getByRole("combobox", { name: "选择工作区" }), otherWorkspace.id); + await user.selectOptions(screen.getByRole("combobox", { name: "选择工作区" }), workspace.id); + fireEvent.change(screen.getByRole("textbox", { name: "开始新对话" }), { target: { value: "新创建请求" } }); + await user.click(screen.getByRole("button", { name: "发送消息" })); + expect(await screen.findByRole("alert")).toHaveTextContent("new request failed"); + await act(async () => { + if (outcome === "creation failed") creation.reject(new Error("old creation failed")); + else creation.resolve({ thread: createdThread } as Awaited>); + }); + await waitFor(() => expect(queries.isMutating()).toBe(0)); + expect(screen.getByRole("alert")).toHaveTextContent("new request failed"); + expect(screen.getByRole("textbox", { name: "开始新对话" })).toHaveValue("新创建请求"); + expect(screen.getByRole("button", { name: "发送消息" })).toBeEnabled(); + expect(window.location.hash).toBe("#/new"); + }); }); diff --git a/web/src/v2/app.tsx b/web/src/v2/app.tsx index b6d07059..2fa77c9f 100644 --- a/web/src/v2/app.tsx +++ b/web/src/v2/app.tsx @@ -12,7 +12,7 @@ import { V2NetworkScopeControl, type V2NetworkMode } from "./components/network- import { V2Settings } from "./components/settings"; import { V2SettingsSidebar, V2Sidebar, type V2SettingsSection } from "./components/sidebar"; import { V2Titlebar } from "./components/titlebar"; -import { createV2Client } from "./client-session"; +import { useV2Client } from "./client-session"; import { v2QueryKeys } from "./query-keys"; import { registerV2RecoveredTurn, useV2RestoreTurns, useV2ThreadTurn, v2TurnFailed } from "./use-thread-turn"; import { V2WorkspaceStart } from "./components/workspace-start"; @@ -45,7 +45,7 @@ function NewConversation({ client, workspaces, workspaceID, onWorkspaceChange, o workspaceID: string; onWorkspaceChange: (workspaceID: string) => void; onImported: (workspace: WorkspaceView, currentDraft?: string) => void; - onCreated: (thread: ThreadView, submittedDraft: string, files: V2FileReference[], images?: WorkspaceImageAttachment[], version?: V2DraftVersion, attachments?: WorkspaceFileAttachment[]) => void; + onCreated: (thread: ThreadView, submittedDraft: string, files: V2FileReference[], images?: WorkspaceImageAttachment[], version?: V2DraftVersion, attachments?: WorkspaceFileAttachment[], shouldOpen?: boolean) => void; onTurnSuccess: (threadID: string, submittedDraft: string) => void; onManageModels: (prepareForDraft?: boolean) => void; draft: string; @@ -60,10 +60,10 @@ function NewConversation({ client, workspaces, workspaceID, onWorkspaceChange, o const recovery = useV2RecoveryStore(); const activeRef = useRef(true); const workspaceRef = useRef(workspaceID); - const modelCatalogRequestRef = useRef(0); + const creationRequestRef = useRef(0); if (workspaceRef.current !== workspaceID) { workspaceRef.current = workspaceID; - modelCatalogRequestRef.current += 1; + creationRequestRef.current += 1; } useEffect(() => { activeRef.current = true; @@ -84,13 +84,15 @@ function NewConversation({ client, workspaces, workspaceID, onWorkspaceChange, o if (phase === "plan" && !client.hasPlanDelivery) throw new Error("当前连接未启用计划确认。请检查连接,或选择直接执行。"); const submittedDraft = draft; setModelCatalogError(""); - const catalogWorkspaceID = workspaceID; - const catalogRequest = ++modelCatalogRequestRef.current; - const catalogRequestIsCurrent = () => activeRef.current && - workspaceRef.current === catalogWorkspaceID && modelCatalogRequestRef.current === catalogRequest; + const creationWorkspaceID = workspaceID; + const creationRequest = ++creationRequestRef.current; + // Leaving this page, changing projects, or starting another request ends + // this request's right to navigate, but not its original input handoff. + const requestIsCurrent = () => activeRef.current && + workspaceRef.current === creationWorkspaceID && creationRequestRef.current === creationRequest; try { const catalog = await client.availableModelRoutes(); - if (!catalogRequestIsCurrent()) return v2ComposerNotSubmitted; + if (!requestIsCurrent()) return v2ComposerNotSubmitted; const selectable = catalog.routes.filter((route) => route.selectable); const selectedReady = !modelRoute || selectable.some((route) => route.provider_id === modelRoute.provider && route.model === modelRoute.model); @@ -99,7 +101,7 @@ function NewConversation({ client, workspaces, workspaceID, onWorkspaceChange, o return v2ComposerNotSubmitted; } } catch (error) { - if (!catalogRequestIsCurrent()) return v2ComposerNotSubmitted; + if (!requestIsCurrent()) return v2ComposerNotSubmitted; setModelCatalogError(`无法检查可用模型,草稿已保留。${error instanceof Error && error.message ? ` ${error.message}` : " 请检查连接后重试。"}`); return v2ComposerNotSubmitted; @@ -115,37 +117,45 @@ function NewConversation({ client, workspaces, workspaceID, onWorkspaceChange, o ...(networkMode === "allowlist" ? { allowed_targets: allowedTargets } : {}), ...(modelRoute ? { provider: modelRoute.provider, model: modelRoute.model } : {}), } as Parameters[0] & { provider?: string; model?: string }; - if (recovery) { - if (managedDraft) { - if (draftVersion) assertV2DraftVersion(managedDraft, draftVersion, { text: submittedDraft, files, images, attachments }); - else draftVersion = requireV2DraftVersion(managedDraft, { text: submittedDraft, files, images, attachments }); + try { + if (recovery) { + if (managedDraft) { + if (draftVersion) assertV2DraftVersion(managedDraft, draftVersion, { text: submittedDraft, files, images, attachments }); + else draftVersion = requireV2DraftVersion(managedDraft, { text: submittedDraft, files, images, attachments }); + } + const intent = creationRecovery.prepare(request, content, files, submittedDraft, images, draftVersion, attachments); + const thread = await creationRecovery.resolve(intent); + const input = creationRecovery.handoff(intent, thread); + const submission = turn.mutateAsync(input); + onCreated(thread, submittedDraft, files, images, input.draftVersion, attachments, requestIsCurrent()); + void queryClient.invalidateQueries({ queryKey: v2QueryKeys.threads("active") }); + try { await submission; } + catch (error) { if (v2TurnFailed(error) && !input.draftVersion) onTurnSuccess(thread.id, submittedDraft); throw error; } + if (!input.draftVersion) onTurnSuccess(thread.id, submittedDraft); + } else { + const fingerprint = JSON.stringify([request, files, images, fileAttachmentIdentities(attachments)]); + const operationID = creationAttemptRef.current.get(fingerprint) ?? globalThis.crypto.randomUUID(); + creationAttemptRef.current.set(fingerprint, operationID); + const result = await client.createThread(request, `v2-thread-create-${operationID}`); + const submission = turn.mutateAsync({ threadID: result.thread.id, workspaceID, content, + ...(files.length ? { files } : {}), + ...(images.length ? { images } : {}), + ...(attachments.length ? { attachments } : {}), + operationKey: `v2-thread-create-turn-${operationID}`, createdAt: new Date().toISOString() }); + onCreated(result.thread, submittedDraft, files, images, undefined, attachments, requestIsCurrent()); + creationAttemptRef.current.delete(fingerprint); + void queryClient.invalidateQueries({ queryKey: v2QueryKeys.threads("active") }); + try { await submission; } + catch (error) { if (v2TurnFailed(error)) onTurnSuccess(result.thread.id, submittedDraft); throw error; } + onTurnSuccess(result.thread.id, submittedDraft); } - const intent = creationRecovery.prepare(request, content, files, submittedDraft, images, draftVersion, attachments); - const thread = await creationRecovery.resolve(intent); - const input = creationRecovery.handoff(intent, thread); - const submission = turn.mutateAsync(input); - onCreated(thread, submittedDraft, files, images, input.draftVersion, attachments); - void queryClient.invalidateQueries({ queryKey: v2QueryKeys.threads("active") }); - try { await submission; } - catch (error) { if (v2TurnFailed(error) && !input.draftVersion) onTurnSuccess(thread.id, submittedDraft); throw error; } - if (!input.draftVersion) onTurnSuccess(thread.id, submittedDraft); - return; + } catch (error) { + // The original key/journal and Thread mutation keep this failure. It + // must not replace a newer request's error after returning to a project. + if (requestIsCurrent()) throw error; + return v2ComposerNotSubmitted; } - const fingerprint = JSON.stringify([request, files, images, fileAttachmentIdentities(attachments)]); - const operationID = creationAttemptRef.current.get(fingerprint) ?? globalThis.crypto.randomUUID(); - creationAttemptRef.current.set(fingerprint, operationID); - const result = await client.createThread(request, `v2-thread-create-${operationID}`); - const submission = turn.mutateAsync({ threadID: result.thread.id, workspaceID, content, - ...(files.length ? { files } : {}), - ...(images.length ? { images } : {}), - ...(attachments.length ? { attachments } : {}), - operationKey: `v2-thread-create-turn-${operationID}`, createdAt: new Date().toISOString() }); - onCreated(result.thread, submittedDraft, files, images, undefined, attachments); - creationAttemptRef.current.delete(fingerprint); - void queryClient.invalidateQueries({ queryKey: v2QueryKeys.threads("active") }); - try { await submission; } - catch (error) { if (v2TurnFailed(error)) onTurnSuccess(result.thread.id, submittedDraft); throw error; } - onTurnSuccess(result.thread.id, submittedDraft); + if (!requestIsCurrent()) return v2ComposerNotSubmitted; }; return