fix: fence canonical chat children to their confirmed route identity

This commit is contained in:
2026-10-31 14:53:56 +09:00
parent 86b2981d63
commit 2924e43773
5 changed files with 170 additions and 30 deletions
@@ -154,7 +154,7 @@ function subject(
memory_fingerprint: memoryFingerprint, memory_fingerprint: memoryFingerprint,
created_at: "2026-01-01T00:00:00Z", created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-02T03:04:05Z", 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); const foundSubject = subjects.find((item) => item.id === subjectId);
if (!foundSubject) return json({}, 404); 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") { if (parts.length === 2 && parts[1] === "journal-browser") {
return await journalBrowserFixture(request, subjectId); return await journalBrowserFixture(request, subjectId);
} }
@@ -337,7 +337,7 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
await paginationPage.getByText("Memory fingerprint", { exact: true }).count(), await paginationPage.getByText("Memory fingerprint", { exact: true }).count(),
0, 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); assertEquals(await paginationPage.getByText("Subject on the next page").count(), 0);
await paginationPage.getByRole("navigation", { name: "Subject pages" }).getByRole( await paginationPage.getByRole("navigation", { name: "Subject pages" }).getByRole(
"link", "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(), await createPage.locator("code.subject-id", { hasText: "created-subject-0001" }).count(),
1, 1,
); );
const currentWorker = createPage.getByText("Worker connection", { exact: true }).locator( await createPage.locator(".subject-body").getByText("No current conversation body.", { exact: true }).waitFor();
"..",
);
assert((await currentWorker.textContent())?.includes("Not connected"));
await createPage.getByText("Resident context has not been generated.").waitFor(); await createPage.getByText("Resident context has not been generated.").waitFor();
await createPage.getByText("No committed Memories yet.").waitFor(); await createPage.getByText("No committed Memories yet.").waitFor();
await createPage.getByRole("button", { name: "Edit" }).click(); await createPage.getByRole("button", { name: "Edit" }).click();
@@ -28,8 +28,7 @@ const captures: Record<string, unknown>[] = [],
failures: string[] = [], failures: string[] = [],
faults: Record<string, unknown>[] = []; faults: Record<string, unknown>[] = [];
const manifest = { const manifest = {
evidence: evidence: "actual-static-Web-build / synthetic-HTTP / NOT-real-Backend-auth-or-Runtime",
"actual-static-Web-build / synthetic-HTTP / NOT-real-Backend-auth-or-Runtime",
source: new TextDecoder().decode( source: new TextDecoder().decode(
(await new Deno.Command("git", { args: ["rev-parse", "HEAD"], cwd: root }) (await new Deno.Command("git", { args: ["rev-parse", "HEAD"], cwd: root })
.output()).stdout, .output()).stdout,
@@ -128,24 +127,23 @@ try {
"**/api/w/memory-review/chat/conversations**", "**/api/w/memory-review/chat/conversations**",
(route) => (route) =>
route.fulfill({ route.fulfill({
json: json: new URL(route.request().url()).pathname.endsWith("/conversations")
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", id: "conversation",
subject_id: "empty-subject", subject_id: "empty-subject",
participant_account_id: "owner", participant_account_id: "owner",
last_user_message_id: null, 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) { async function direct(path: string, selector: string, canonical = path) {
@@ -185,6 +183,10 @@ try {
); );
} }
async function capture(id: string) { 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`; const image = `${colorScheme}-${width}-${id}.png`;
await page.screenshot({ path: join(output, image) }); await page.screenshot({ path: join(output, image) });
const overflow = await page.evaluate( const overflow = await page.evaluate(
@@ -205,9 +207,7 @@ try {
route: page.url(), route: page.url(),
colorScheme, colorScheme,
width, width,
mainWidth: await page.locator("main").evaluate((el) => mainWidth: await page.locator("main").evaluate((el) => el.clientWidth),
el.clientWidth
),
overflow, overflow,
alerts: await page.getByRole("alert").allTextContents(), alerts: await page.getByRole("alert").allTextContents(),
snapshot: await page.locator("body").ariaSnapshot(), snapshot: await page.locator("body").ariaSnapshot(),
@@ -374,9 +374,7 @@ try {
"This legacy path has no corresponding Subject page.", "This legacy path has no corresponding Subject page.",
).waitFor(); ).waitFor();
assertEquals( assertEquals(
requests.slice(beforeUnknown).filter((p) => requests.slice(beforeUnknown).filter((p) => p.includes("/subjektiv/subjects/")),
p.includes("/subjektiv/subjects/")
),
[], [],
); );
// 読取り拒否を空一覧/新権限付与へ変えず、UIがBackend失敗をそのまま示す。 // 読取り拒否を空一覧/新権限付与へ変えず、UIがBackend失敗をそのまま示す。
@@ -8,7 +8,10 @@
import type { PageData as RouteData } from './$types'; import type { PageData as RouteData } from './$types';
let { data }: { data: ResolvedPageData<RouteData> } = $props(); let { data }: { data: ResolvedPageData<RouteData> } = $props();
const subject = $derived(data.subject.data); const subject = $derived(data.subject.data);
let accountConfirmed = $state(false); // route再利用の描画はeffectより先に進むため、確認済みboolを別identityへ持ち越さない。
let confirmedIdentity = $state<string | null>(null);
const identity = $derived(JSON.stringify([data.accountId, data.workspaceId, data.subjectId]));
const accountConfirmed = $derived(confirmedIdentity === identity);
$effect(() => { $effect(() => {
const accountId = data.accountId; const accountId = data.accountId;
const workspaceId = data.workspaceId; const workspaceId = data.workspaceId;
@@ -17,10 +20,11 @@
const generation = browserSession.authenticationGeneration; const generation = browserSession.authenticationGeneration;
const stamp = browserSession.authenticationStamp; const stamp = browserSession.authenticationStamp;
return browserSession.subscribe((snapshot) => { return browserSession.subscribe((snapshot) => {
accountConfirmed = data.workspaceId === workspaceId && data.subjectId === subjectId && confirmedIdentity = data.workspaceId === workspaceId && data.subjectId === subjectId &&
browserSession.authenticationGeneration === generation && browserSession.authenticationStamp === stamp && browserSession.authenticationGeneration === generation && browserSession.authenticationStamp === stamp &&
snapshot.kind === 'ready' && snapshot.value.session.kind === 'authenticated' && 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;
}); });
}); });
}); });
@@ -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);
});