fix: fence canonical chat children to their confirmed route identity
This commit is contained in:
@@ -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,8 +127,7 @@ 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",
|
id: "conversation",
|
||||||
subject_id: "empty-subject",
|
subject_id: "empty-subject",
|
||||||
@@ -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失敗をそのまま示す。
|
||||||
|
|||||||
+7
-3
@@ -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;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+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