fix: fence canonical chat children to their confirmed route identity
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -28,8 +28,7 @@ const captures: Record<string, unknown>[] = [],
|
||||
failures: string[] = [],
|
||||
faults: Record<string, unknown>[] = [];
|
||||
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失敗をそのまま示す。
|
||||
|
||||
+7
-3
@@ -8,7 +8,10 @@
|
||||
import type { PageData as RouteData } from './$types';
|
||||
let { data }: { data: ResolvedPageData<RouteData> } = $props();
|
||||
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(() => {
|
||||
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;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+125
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user