diff --git a/tools/web-ux/browser-tests/memory_document_fixture_server.ts b/tools/web-ux/browser-tests/memory_document_fixture_server.ts index 0581f0566..f7903cf44 100644 --- a/tools/web-ux/browser-tests/memory_document_fixture_server.ts +++ b/tools/web-ux/browser-tests/memory_document_fixture_server.ts @@ -154,7 +154,7 @@ function subject( memory_fingerprint: memoryFingerprint, created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-02T03:04:05Z", - ...(currentWorker ? { current_worker: currentWorker } : {}), + ...(currentWorker ? { current_worker: currentWorker, current_body: { runtime_id: currentWorker.runtime_id, worker_id: currentWorker.worker_id, generation: "17" } } : {}), }; } @@ -596,6 +596,22 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { const foundSubject = subjects.find((item) => item.id === subjectId); if (!foundSubject) return json({}, 404); + // 合成HTTPでもBody GETを明示する。未対応APIへのHTML fallbackを観測成功にしない。 + // mutationはこのfixtureでは提供せず、IA検証で起動停止の成功を偽装しない。 + if (parts.length === 2 && parts[1] === "body" && request.method === "GET") { + const worker = foundSubject.current_worker; + return json({ + workspace_id: workspaceId, + subject_id: subjectId, + current: worker ? { runtime_id: worker.runtime_id, worker_id: worker.worker_id, generation: "17" } : null, + observation: worker ? { + kind: "available", + worker: { ...worker, resource_key: "W-fixture", availability: "observed", worker_state: { last_command_id: 1, state: { kind: "idle" } }, restore_observation_token: "synthetic-body-observation" }, + } : { kind: "absent" }, + can_start: foundSubject.state === "active", + can_stop: foundSubject.state === "active", + }); + } if (parts.length === 2 && parts[1] === "journal-browser") { return await journalBrowserFixture(request, subjectId); } diff --git a/tools/web-ux/browser-tests/memory_document_shell_test.ts b/tools/web-ux/browser-tests/memory_document_shell_test.ts index 10143bb5f..45fb934a4 100644 --- a/tools/web-ux/browser-tests/memory_document_shell_test.ts +++ b/tools/web-ux/browser-tests/memory_document_shell_test.ts @@ -337,7 +337,7 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco await paginationPage.getByText("Memory fingerprint", { exact: true }).count(), 0, ); - assert((await paginationPage.getByText("Not connected", { exact: true }).count()) > 0); + assert((await paginationPage.getByText("No current body", { exact: true }).count()) > 0); assertEquals(await paginationPage.getByText("Subject on the next page").count(), 0); await paginationPage.getByRole("navigation", { name: "Subject pages" }).getByRole( "link", @@ -451,10 +451,7 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco await createPage.locator("code.subject-id", { hasText: "created-subject-0001" }).count(), 1, ); - const currentWorker = createPage.getByText("Worker connection", { exact: true }).locator( - "..", - ); - assert((await currentWorker.textContent())?.includes("Not connected")); + await createPage.locator(".subject-body").getByText("No current conversation body.", { exact: true }).waitFor(); await createPage.getByText("Resident context has not been generated.").waitFor(); await createPage.getByText("No committed Memories yet.").waitFor(); await createPage.getByRole("button", { name: "Edit" }).click(); diff --git a/tools/web-ux/browser-tests/subject_navigation_review.ts b/tools/web-ux/browser-tests/subject_navigation_review.ts index 3c1077a6a..9aed5a780 100644 --- a/tools/web-ux/browser-tests/subject_navigation_review.ts +++ b/tools/web-ux/browser-tests/subject_navigation_review.ts @@ -28,8 +28,7 @@ const captures: Record[] = [], failures: string[] = [], faults: Record[] = []; const manifest = { - evidence: - "actual-static-Web-build / synthetic-HTTP / NOT-real-Backend-auth-or-Runtime", + evidence: "actual-static-Web-build / synthetic-HTTP / NOT-real-Backend-auth-or-Runtime", source: new TextDecoder().decode( (await new Deno.Command("git", { args: ["rev-parse", "HEAD"], cwd: root }) .output()).stdout, @@ -128,24 +127,23 @@ try { "**/api/w/memory-review/chat/conversations**", (route) => route.fulfill({ - json: - new URL(route.request().url()).pathname.endsWith("/conversations") - ? { + json: new URL(route.request().url()).pathname.endsWith("/conversations") + ? { + id: "conversation", + subject_id: "empty-subject", + participant_account_id: "owner", + last_user_message_id: null, + } + : { + conversation: { id: "conversation", subject_id: "empty-subject", participant_account_id: "owner", last_user_message_id: null, - } - : { - conversation: { - id: "conversation", - subject_id: "empty-subject", - participant_account_id: "owner", - last_user_message_id: null, - }, - messages: [], - has_more: false, }, + messages: [], + has_more: false, + }, }), ); async function direct(path: string, selector: string, canonical = path) { @@ -185,6 +183,10 @@ try { ); } async function capture(id: string) { + if (id === "detail" || id === "chat") { + await page.locator(".subject-body .actions").waitFor(); + assertEquals(await page.locator(".subject-body [role=alert]").count(), 0); + } const image = `${colorScheme}-${width}-${id}.png`; await page.screenshot({ path: join(output, image) }); const overflow = await page.evaluate( @@ -205,9 +207,7 @@ try { route: page.url(), colorScheme, width, - mainWidth: await page.locator("main").evaluate((el) => - el.clientWidth - ), + mainWidth: await page.locator("main").evaluate((el) => el.clientWidth), overflow, alerts: await page.getByRole("alert").allTextContents(), snapshot: await page.locator("body").ariaSnapshot(), @@ -374,9 +374,7 @@ try { "This legacy path has no corresponding Subject page.", ).waitFor(); assertEquals( - requests.slice(beforeUnknown).filter((p) => - p.includes("/subjektiv/subjects/") - ), + requests.slice(beforeUnknown).filter((p) => p.includes("/subjektiv/subjects/")), [], ); // 読取り拒否を空一覧/新権限付与へ変えず、UIがBackend失敗をそのまま示す。 diff --git a/web/workspace/src/routes/w/[workspaceId]/subjektiv/[subjectId]/chat/PageContent.svelte b/web/workspace/src/routes/w/[workspaceId]/subjektiv/[subjectId]/chat/PageContent.svelte index 184c9e39d..bf2be37a0 100644 --- a/web/workspace/src/routes/w/[workspaceId]/subjektiv/[subjectId]/chat/PageContent.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/subjektiv/[subjectId]/chat/PageContent.svelte @@ -8,7 +8,10 @@ import type { PageData as RouteData } from './$types'; let { data }: { data: ResolvedPageData } = $props(); const subject = $derived(data.subject.data); - let accountConfirmed = $state(false); + // route再利用の描画はeffectより先に進むため、確認済みboolを別identityへ持ち越さない。 + let confirmedIdentity = $state(null); + const identity = $derived(JSON.stringify([data.accountId, data.workspaceId, data.subjectId])); + const accountConfirmed = $derived(confirmedIdentity === identity); $effect(() => { const accountId = data.accountId; const workspaceId = data.workspaceId; @@ -17,10 +20,11 @@ const generation = browserSession.authenticationGeneration; const stamp = browserSession.authenticationStamp; return browserSession.subscribe((snapshot) => { - accountConfirmed = data.workspaceId === workspaceId && data.subjectId === subjectId && + confirmedIdentity = data.workspaceId === workspaceId && data.subjectId === subjectId && browserSession.authenticationGeneration === generation && browserSession.authenticationStamp === stamp && snapshot.kind === 'ready' && snapshot.value.session.kind === 'authenticated' && - snapshot.value.actor?.account_id === accountId; + snapshot.value.actor?.account_id === accountId + ? JSON.stringify([accountId, workspaceId, subjectId]) : null; }); }); }); diff --git a/web/workspace/src/routes/w/[workspaceId]/subjektiv/[subjectId]/chat/route-integration.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/subjektiv/[subjectId]/chat/route-integration.browser.spec.ts new file mode 100644 index 000000000..1bc816862 --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/subjektiv/[subjectId]/chat/route-integration.browser.spec.ts @@ -0,0 +1,125 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen, waitFor } from "@testing-library/svelte"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import PageContent from "./PageContent.svelte"; +import { browserSession } from "#lib/workspace/auth/session-state.ts"; +import { authUser } from "#lib/workspace/auth/api.test-fixtures.ts"; + +const calls: string[] = []; +function routeData( + workspaceId = "w", + subjectId = "subject-a", + accountId = authUser.account_id, +) { + return { + workspaceId, + subjectId, + accountId, + subject: { + data: { id: subjectId, role: `Companion ${subjectId}`, state: "active" }, + error: null, + }, + }; +} +beforeEach(() => { + calls.length = 0; + localStorage.clear(); + sessionStorage.clear(); + browserSession.snapshot = { + kind: "ready", + value: { + actor: { ...authUser, auth_method: "browser_session" }, + authentication_reference: "route-integration-fixture", + session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }, + }, + }; + vi.stubGlobal("fetch", (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input), "https://fixture.invalid"); + calls.push(`${init?.method ?? "GET"} ${url.pathname}`); + const conversation = { + id: "conversation", + subject_id: "subject-a", + participant_account_id: authUser.account_id, + last_user_message_id: null, + }; + if (url.pathname.endsWith("/body")) { + return Promise.resolve(Response.json({ + workspace_id: "w", + subject_id: "subject-a", + current: null, + observation: { kind: "absent" }, + can_start: true, + can_stop: true, + })); + } + if (url.pathname.endsWith("/conversations")) { + return Promise.resolve(Response.json(conversation)); + } + return Promise.resolve( + Response.json({ conversation, messages: [], has_more: false }), + ); + }); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + sessionStorage.clear(); + localStorage.clear(); +}); + +test("canonical chat hosts Body and public conversation as siblings with a canonical return link", async () => { + render(PageContent, { data: routeData() } as never); + await screen.findByText("No current conversation body."); + await screen.findByRole("textbox", { + name: "Message to Companion subject-a", + }); + expect( + screen.getByRole("link", { name: "← Subject details" }).getAttribute( + "href", + ), + ).toBe("/w/w/subjektiv/subject-a"); + const body = screen.getByRole("heading", { name: "Conversation body" }) + .closest("section")!; + const conversation = screen.getByRole("textbox").closest("section")!; + expect(body.parentElement).toBe(conversation.parentElement); + expect(body.contains(conversation)).toBe(false); + expect(calls).toContain("GET /api/w/w/subjektiv/subjects/subject-a/body"); + expect(calls.some((call) => call.includes("/api/w/w/chat/conversations"))) + .toBe(true); + expect(calls.some((call) => /\/body\/(start|stop)/.test(call))).toBe(false); +}); + +test("auth-generation change removes both children even when the account snapshot is preserved", async () => { + render(PageContent, { data: routeData() } as never); + await screen.findByRole("textbox"); + browserSession.invalidate(true); + await screen.findByRole("alert"); + expect(screen.queryByRole("textbox")).toBeNull(); + expect(screen.queryByRole("heading", { name: "Conversation body" })) + .toBeNull(); + expect(screen.getByRole("alert").textContent).toContain("Reopen this page"); +}); + +test("route reuse with an unconfirmed account never mounts controls or reads the new Subject", async () => { + const view = render(PageContent, { data: routeData() } as never); + await screen.findByRole("textbox"); + calls.length = 0; + await view.rerender( + { + data: routeData( + "other/workspace", + "other/subject", + "unconfirmed-account", + ), + } as never, + ); + await waitFor(() => expect(screen.queryByRole("textbox")).toBeNull()); + expect(screen.queryByRole("heading", { name: "Conversation body" })) + .toBeNull(); + expect( + screen.getByRole("link", { name: "← Subject details" }).getAttribute( + "href", + ), + ).toBe("/w/other%2Fworkspace/subjektiv/other%2Fsubject"); + expect(calls.some((call) => call.includes("other"))).toBe(false); +});