feat: connect Subject conversation route and validate exact retries

This commit is contained in:
2026-10-31 14:15:27 +09:00
parent bf23f5a4e4
commit 1c35089c77
12 changed files with 1160 additions and 16 deletions
+6 -1
View File
@@ -170,8 +170,13 @@ mod tests {
let normalize = |value: &str| { let normalize = |value: &str| {
value value
.chars() .chars()
.filter(|c| !c.is_whitespace()) .filter_map(|c| match c {
c if c.is_whitespace() => None,
',' => Some(';'),
c => Some(c),
})
.collect::<String>() .collect::<String>()
.replace(";}", "}")
}; };
assert_eq!(normalize(&actual), normalize(&chat_api_typescript())); assert_eq!(normalize(&actual), normalize(&chat_api_typescript()));
} }
@@ -619,6 +619,149 @@ async fn chat_sqlite_append_failure_returns_no_success_or_notify_and_rolls_back_
); );
} }
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn chat_web_create_operation_uses_real_account_membership_and_workspace_storage_scope() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_server_config(dir.path());
config.repositories.clear();
let control = Arc::new(test_control_store(&config));
seed_test_registered_workspace(control.as_ref(), &config)
.await
.unwrap();
let mut first_workspace = control
.get_workspace(TEST_WORKSPACE_ID)
.await
.unwrap()
.unwrap();
let mut other_workspace = first_workspace.clone();
other_workspace.workspace_id = "chat-other-workspace".into();
other_workspace.display_name = "Other chat Workspace".into();
control.upsert_workspace(&other_workspace).await.unwrap();
let second_config = config
.for_catalog_workspace(&other_workspace, vec![])
.unwrap();
let a = WorkspaceApi::new_for_test(config, control.clone())
.await
.unwrap();
let b = WorkspaceApi::new_for_test(second_config, control.clone())
.await
.unwrap();
let owner = seed_test_workspace_owner_token(control.as_ref(), "chat-web-scope-owner").await;
let another = seed_test_api_token(control.as_ref(), "chat-web-next-owner");
let mut created = Vec::new();
for (api, workspace) in [(&a, TEST_WORKSPACE_ID), (&b, "chat-other-workspace")] {
let (status, value) = account(api, &owner, "POST", &format!("/api/w/{workspace}/subjektiv/subjects"),
Some(json!({"operation_id":"same-subject-create-operation","role":"Web conversation scope","behavior_md":""}))).await;
assert_eq!(status, StatusCode::CREATED, "{value}");
let subject = value["id"].as_str().unwrap().to_owned();
// Deliberately reuse the exact operation across both real Workspace stores,
// with different Subject input. A global operation namespace would conflict.
let wire =
json!({"operation_id":"web-subject-conversation/v1/scope-probe","subject_id":subject});
let path = format!("/api/w/{workspace}/chat/conversations");
let (status, conversation) = account(api, &owner, "POST", &path, Some(wire.clone())).await;
assert_eq!(status, StatusCode::OK, "{conversation}");
assert_eq!(
account(api, &owner, "POST", &path, Some(wire.clone()))
.await
.1,
conversation
);
created.push((subject, conversation, wire));
}
let (subject, first, wire) = &created[0];
assert_ne!(first["id"], created[1].1["id"]);
assert_ne!(subject, &created[1].0);
let first_id = first["id"].as_str().unwrap();
for (api, workspace, foreign) in [
(&a, TEST_WORKSPACE_ID, created[1].1["id"].as_str().unwrap()),
(&b, "chat-other-workspace", first_id),
] {
let path = format!("/api/w/{workspace}/chat/conversations/{foreign}/messages");
assert_eq!(
account(api, &owner, "GET", &path, None).await.0,
StatusCode::FORBIDDEN
);
assert_eq!(
account(
api,
&owner,
"POST",
&path,
Some(json!({"operation_id":"cross-workspace","text":"must not append"}))
)
.await
.0,
StatusCode::FORBIDDEN
);
}
// The public Account route is owner-only and ownership is immutable. Do not
// invent an ownership transfer merely to make a second Account test pass.
first_workspace.owner_account_id = "account-chat-web-next-owner".into();
assert!(control.upsert_workspace(&first_workspace).await.is_err());
assert_eq!(
account(
&a,
&another,
"POST",
&conversations_path(),
Some(wire.clone())
)
.await
.0,
StatusCode::FORBIDDEN
);
assert_eq!(
account(&a, &another, "GET", &messages_path(first_id), None)
.await
.0,
StatusCode::FORBIDDEN
);
assert_eq!(
account(
&a,
&owner,
"POST",
&conversations_path(),
Some(wire.clone())
)
.await
.1,
*first
);
// Separately exercise the real FeatureStorage Account operation namespace.
// This trusted-principal store call is NOT a public authorization bypass or
// evidence that a non-owner can create a conversation through HTTP.
let store = open_subjektiv_store(&a).unwrap();
let request: server_api::ChatCreateConversationRequest =
serde_json::from_value(wire.clone()).unwrap();
let second_account = store
.chat_create_conversation("account-chat-web-next-owner", &request)
.unwrap();
assert_ne!(second_account.id, first_id);
assert_eq!(
store
.chat_create_conversation("account-chat-web-next-owner", &request)
.unwrap(),
second_account
);
assert!(
store
.chat_messages_for_account(
&second_account.participant_account_id,
first_id,
&Default::default()
)
.is_err()
);
assert_eq!(
account(&a, &owner, "GET", &messages_path(&second_account.id), None)
.await
.0,
StatusCode::FORBIDDEN
);
}
/// Only the model is scripted. ConversationRead/Send execute through the real /// Only the model is scripted. ConversationRead/Send execute through the real
/// installed Feature, signed Runtime request transport, Backend and SQLite. /// installed Feature, signed Runtime request transport, Backend and SQLite.
#[derive(Clone)] #[derive(Clone)]
@@ -0,0 +1,309 @@
#!/usr/bin/env -S deno run -A
/** 実Webの会話画面を合成HTTPで撮影する。実Backend/Runtimeの証拠ではない。 */
import { assert, assertEquals } from "@std/assert";
import { dirname, fromFileUrl, join, resolve } from "@std/path";
import { type Browser, chromium, type Page, type Request } from "playwright";
import { sha256File } from "../src/artifacts.ts";
import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts";
import { assertNoPageWideOverflow, freePort } from "./memory_document_harness.ts";
const root = resolve(dirname(fromFileUrl(import.meta.url)), "../../..");
const [buildArg, runId, mode] = Deno.args;
if (
!buildArg || !runId || !/^[a-zA-Z0-9._-]+$/.test(runId) || !["before", "after"].includes(mode)
) {
throw new Error(
"usage: subject_conversation_review.ts <actual-build-path> <unique-run-id> <before|after>",
);
}
const buildRoot = resolve(buildArg);
await Deno.stat(join(buildRoot, "index.html"));
const output = join(root, "target/web-ux/t808", runId);
await Deno.mkdir(dirname(output), { recursive: true });
await Deno.mkdir(output);
const port = await freePort();
const base = `http://127.0.0.1:${port}`;
const subject = "empty-subject";
const subjectRoute = `/w/memory-review/memory/${subject}`;
const captures: unknown[] = [];
const failures: string[] = [];
const faults: { kind: string; message: string; expected: boolean }[] = [];
const git = async (args: string[]) => {
const result = await new Deno.Command("git", { args, cwd: root, stdout: "piped" }).output();
return result.success ? new TextDecoder().decode(result.stdout).trim() : null;
};
const manifest = {
evidence: "actual-Web-with-synthetic-HTTP-NOT-real-Backend-or-Runtime",
mode,
buildRoot,
buildIndexSha256: await sha256File(join(buildRoot, "index.html")),
source: {
commit: await git(["rev-parse", "HEAD"]),
status: await git(["status", "--porcelain"]),
runnerSha256: await sha256File(fromFileUrl(import.meta.url)),
},
browser: { name: "chromium", version: "" },
visualVerdict: "UNREVIEWED — implementation author must ViewImage and inspect this context",
status: "running",
captures,
faults,
failures,
};
const save = () =>
Deno.writeTextFile(join(output, "review-context.json"), JSON.stringify(manifest, null, 2));
async function capture(page: Page, id: string) {
const image = join(output, `${id}.png`);
const accessibility = join(output, `${id}.aria.txt`);
await page.screenshot({ path: image });
await Deno.writeTextFile(accessibility, await page.locator("body").ariaSnapshot());
captures.push({
id,
route: new URL(page.url()).pathname,
viewport: page.viewportSize(),
image,
imageSha256: await sha256File(image),
accessibility,
accessibilitySha256: await sha256File(accessibility),
visibleAlerts: await page.getByRole("alert").allTextContents(),
focus: await page.locator(":focus").evaluateAll((els) =>
els.map((el) => ({ tag: el.tagName, id: el.id }))
),
mainWidth: await page.locator("main").evaluate((el) => el.clientWidth),
reviewed: false,
});
await assertNoPageWideOverflow(page);
await save();
}
const owned = await startOwnedProcesses(
[{
id: "conversation-fixture",
command: Deno.execPath(),
cwd: join(root, "tools/web-ux"),
args: [
"run",
"--cached-only",
"-A",
join(root, "tools/web-ux/browser-tests/memory_document_fixture_server.ts"),
String(port),
buildRoot,
"3",
],
readyUrl: `${base}/health`,
}],
join(output, "scenario.json"),
join(output, "logs"),
[],
);
let browser: Browser | undefined;
try {
browser = await chromium.launch({ headless: true });
manifest.browser.version = browser.version();
for (const theme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390]) {
const prefix = `${theme}-${width}`;
const context = await browser.newContext({
viewport: { width, height: 900 },
colorScheme: theme,
reducedMotion: "reduce",
});
const page = await context.newPage();
page.setDefaultTimeout(15000);
const deniedRequests = new WeakSet<Request>();
const navigationRequests = new WeakSet<Request>();
const pendingRequests = new Set<Request>();
let leaving = false;
function beginNavigation() {
leaving = true;
for (const request of pendingRequests) navigationRequests.add(request);
}
page.on("request", (request) => {
pendingRequests.add(request);
if (leaving) navigationRequests.add(request);
});
page.on("requestfinished", (request) => pendingRequests.delete(request));
page.on("requestfailed", (request) => pendingRequests.delete(request));
let faultMode: "normal" | "ack-loss" | "forbidden" = "normal";
page.on(
"pageerror",
(e) => faults.push({ kind: "pageerror", message: e.message, expected: false }),
);
page.on("console", (e) => {
if (["warning", "error"].includes(e.type())) {
faults.push({
kind: `console-${e.type()}`,
message: e.text(),
expected: faultMode !== "normal" && e.location().url.includes("/chat/conversations"),
});
}
});
page.on("requestfailed", (r) =>
faults.push({
kind: "requestfailed",
message: `${r.method()} ${r.url()} ${r.failure()?.errorText}`,
expected: (faultMode === "ack-loss" && r.url().endsWith("/messages")) ||
((navigationRequests.has(r) || deniedRequests.has(r)) && r.method() === "GET" &&
r.failure()?.errorText === "net::ERR_ABORTED"),
}));
page.on("response", (r) => {
if (r.status() >= 400) {
faults.push({
kind: "http",
message: `${r.status()} ${r.url()}`,
expected: faultMode === "forbidden" && r.url().includes("/chat/conversations") &&
r.status() === 403,
});
}
});
try {
const response = await page.goto(`${base}${subjectRoute}`);
assertEquals(response?.status(), 200);
await page.getByRole("heading", { level: 1, name: "Empty ready subject" }).waitFor();
await capture(page, `${prefix}-subject`);
if (mode === "before") {
continue;
}
const messages: Record<string, unknown>[] = [];
const requests: Record<string, unknown>[] = [];
const conversation = {
id: "public-conversation",
subject_id: subject,
participant_account_id: "owner",
last_user_message_id: null as string | null,
};
await context.route("**/api/w/memory-review/chat/conversations**", async (route) => {
const request = route.request();
const url = new URL(request.url());
if (faultMode === "forbidden") {
deniedRequests.add(request);
return await route.fulfill({
status: 403,
json: { error: "synthetic permission rejection" },
});
}
if (url.pathname.endsWith("/conversations")) {
return await route.fulfill({ json: { ...conversation, last_user_message_id: null } });
}
if (request.method() === "POST") {
const body = request.postDataJSON();
requests.push(body);
const saved = messages.find((m) => m.operation_id === body.operation_id);
const message = saved ??
{
id: `m${messages.length + 1}`,
conversation_id: conversation.id,
sequence: messages.length + 1,
sender: { kind: "user", account_id: "owner" },
...body,
last_user_message_id: `m${messages.length + 1}`,
};
if (!saved) messages.push(message);
conversation.last_user_message_id = String(message.id);
if (faultMode === "ack-loss") return await route.abort("failed");
return await route.fulfill({
json: { message, delivery: { state: "accepted", runtime_id: "r", worker_id: "w" } },
});
}
const after = Number(url.searchParams.get("after_sequence") ?? 0);
return await route.fulfill({
json: {
conversation,
messages: messages.filter((m) => Number(m.sequence) > after).slice(0, 100),
has_more: messages.filter((m) => Number(m.sequence) > after).length > 100,
},
});
});
beginNavigation();
await page.getByRole("link", { name: "Open conversation" }).click();
await page.getByText("No public messages yet.").waitFor();
leaving = false;
const input = page.getByRole("textbox", { name: "Message to Empty ready subject" });
await input.focus();
await capture(page, `${prefix}-empty-keyboard`);
faultMode = "ack-loss";
const original = " Keep the exact original input.\n日本語の本文 " +
"long-unbroken-value-".repeat(12);
await input.fill(original);
await page.getByRole("button", { name: "Send message", exact: true }).click();
await page.getByRole("alert").filter({ hasText: /outcome could not be confirmed/ })
.waitFor();
await input.fill("Next draft stays editable, but cannot replace the unknown send.");
assert(await page.getByRole("button", { name: "Send message", exact: true }).isDisabled());
await page.getByRole("button", { name: "Retry same input" }).scrollIntoViewIfNeeded();
await capture(page, `${prefix}-unknown-retained`);
faultMode = "normal";
const secondTab = await context.newPage();
try {
await secondTab.goto(`${base}${subjectRoute}/conversation`);
await secondTab.getByRole("textbox").fill("Independent input from another tab");
await secondTab.getByRole("button", { name: "Send message", exact: true }).click();
await secondTab.getByText(
"Runtime accepted this input. Processing and replies are not confirmed.",
).waitFor();
assertEquals(requests.length, 2);
assert(requests[0].operation_id !== requests[1].operation_id);
assert(
await page.getByRole("button", { name: "Send message", exact: true }).isDisabled(),
);
} finally {
await secondTab.close();
}
beginNavigation();
await page.reload();
await page.getByText("Previous send outcome is unknown. Its original input is retained.")
.waitFor();
faultMode = "normal";
leaving = false;
await page.getByRole("button", { name: "Retry same input" }).click();
await page.getByText(
"Runtime accepted this input. Processing and replies are not confirmed.",
).waitFor();
assertEquals(requests.length, 3);
assertEquals(requests[0], requests[2]);
assertEquals(messages.length, 2);
messages.push({
id: "m3",
conversation_id: conversation.id,
sequence: 3,
sender: { kind: "subject", subject_id: subject },
text: "Explicit public reply — not internal assistant output.\n" +
"long-unbroken-value-".repeat(16),
reply_to: "m1",
work_ref: null,
operation_id: "subject-publish",
last_user_message_id: "m2",
});
await page.getByRole("button", { name: "Refresh history", exact: true }).click();
await page.getByText(/Explicit public reply/).waitFor();
await input.scrollIntoViewIfNeeded();
await capture(page, `${prefix}-saved-and-accepted`);
faultMode = "forbidden";
await page.getByRole("button", { name: "Refresh history", exact: true }).click();
await page.getByRole("alert").filter({ hasText: /do not have access/ }).waitFor();
assertEquals(await page.getByRole("textbox").count(), 0);
await capture(page, `${prefix}-permission-denied`);
} catch (e) {
failures.push(`${prefix}: ${e instanceof Error ? e.stack : e}`);
await capture(page, `${prefix}-failure`).catch(() => {});
throw e;
} finally {
beginNavigation();
await context.close();
}
}
}
assertEquals(faults.filter((f) => !f.expected), []);
manifest.status = mode === "before"
? "baseline-captured-unreviewed"
: "browser-assertions-passed-unreviewed";
} catch (e) {
manifest.status = "failed";
throw e;
} finally {
try {
await browser?.close();
} finally {
await stopOwnedProcesses(owned);
await save();
console.log(join(output, "review-context.json"));
}
}
@@ -0,0 +1,85 @@
// Generated from server-api. Do not edit by hand.
// Regenerate: cargo run -q -p server-api --features typescript --example generate_chat_api_types > web/workspace/src/lib/generated/chat-api.ts
export const CHAT_LIMITS = {
maxTextBytes: 16384,
maxPageLimit: 100,
maxCounter: 9007199254740991,
} as const;
export type ChatConversation = {
id: string;
subject_id: string;
participant_account_id: string;
/**
* Latest user input in this conversation; empty conversations use null.
*/
last_user_message_id: string | null;
};
export type ChatMessageSender = { "kind": "user"; account_id: string } | {
"kind": "subject";
subject_id: string;
};
export type ChatMessage = {
id: string;
conversation_id: string;
sequence: number;
sender: ChatMessageSender;
text: string;
reply_to: string | null;
/**
* Opaque related-work reference, not a grant or an attachment path.
*/
work_ref: string | null;
operation_id: string;
/**
* User messages refer to themselves; Subject messages pin the latest observed user input.
*/
last_user_message_id: string | null;
};
export type ChatCreateConversationRequest = {
operation_id: string;
subject_id: string;
};
export type ChatUserMessageRequest = {
operation_id: string;
text: string;
reply_to: string | null;
work_ref: string | null;
};
export type ChatMessagePageQuery = {
after_sequence: number | null;
limit: number | null;
};
export type ChatMessagePage = {
conversation: ChatConversation;
messages: Array<ChatMessage>;
has_more: boolean;
};
export type ChatDelivery = { "state": "no_current_body" } | {
"state": "accepted";
runtime_id: string;
worker_id: string;
} | {
"state": "rejected";
runtime_id: string;
worker_id: string;
detail: string;
} | {
"state": "unknown";
runtime_id: string;
worker_id: string;
detail: string;
} | { "state": "unavailable"; detail: string };
export type ChatReceiveResponse = {
message: ChatMessage;
delivery: ChatDelivery;
};
@@ -0,0 +1,338 @@
// @vitest-environment happy-dom
// 本番component/API adapterと接続し、HTTPとbrowser storage障害だけを代替する。
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/svelte";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import SubjectConversation from "./SubjectConversation.svelte";
import { browserSession } from "#lib/workspace/auth/session-state.ts";
import { authUser } from "#lib/workspace/auth/api.test-fixtures.ts";
import { conversationAttemptKey, readConversationAttempt } from "./conversation-attempt.ts";
import type { ChatMessage, ChatUserMessageRequest } from "#lib/generated/chat-api.ts";
const props = {
workspaceId: "w",
subjectId: "s",
accountId: authUser.account_id,
subjectName: "Research companion",
};
function authenticate(account = props.accountId) {
browserSession.snapshot = {
kind: "ready",
value: {
actor: {
...authUser,
account_id: account,
auth_method: "browser_session",
},
authentication_reference: "fixture-session",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
},
};
}
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
authenticate();
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
sessionStorage.clear();
localStorage.clear();
});
function harness() {
const messages: ChatMessage[] = [];
const posts: ChatUserMessageRequest[] = [];
let mode:
| "accepted"
| "lost"
| "conflict"
| "failed"
| "no_current_body"
| "unknown"
| "rejected" = "accepted";
let readStatus = 200;
const conversation = {
id: "c",
subject_id: props.subjectId,
participant_account_id: props.accountId,
last_user_message_id: null as string | null,
};
vi.stubGlobal(
"fetch",
vi.fn(async (input: string, init?: RequestInit) => {
const url = new URL(String(input), "http://fixture.invalid");
if (url.pathname.endsWith("/conversations")) {
return Response.json({ ...conversation, last_user_message_id: null });
}
if (init?.method === "POST") {
const body = JSON.parse(String(init.body)) as ChatUserMessageRequest;
posts.push(body);
if (mode === "conflict" || mode === "failed") {
return new Response(null, {
status: mode === "conflict" ? 409 : 500,
});
}
let message = messages.find((m) => m.operation_id === body.operation_id);
if (!message) {
message = {
id: `m${messages.length + 1}`,
conversation_id: "c",
sequence: messages.length + 1,
sender: { kind: "user", account_id: props.accountId },
...body,
last_user_message_id: `m${messages.length + 1}`,
};
messages.push(message);
}
conversation.last_user_message_id = message.id;
if (mode === "lost") throw new TypeError("ACK lost after persistence");
const delivery = mode === "no_current_body"
? { state: mode }
: mode === "accepted"
? { state: mode, runtime_id: "r", worker_id: "w" }
: { state: mode, runtime_id: "r", worker_id: "w", detail: "fixture" };
return Response.json({ message, delivery });
}
if (readStatus !== 200) return new Response(null, { status: readStatus });
const after = Number(url.searchParams.get("after_sequence") ?? 0);
const newer = messages.filter((m) => m.sequence > after);
return Response.json({
conversation,
messages: newer.slice(0, 100),
has_more: newer.length > 100,
});
}),
);
function reply(text: string) {
messages.push({
id: `m${messages.length + 1}`,
conversation_id: "c",
sequence: messages.length + 1,
sender: { kind: "subject", subject_id: props.subjectId },
text,
reply_to: null,
work_ref: null,
operation_id: `reply-${messages.length + 1}`,
last_user_message_id: conversation.last_user_message_id,
});
}
return {
messages,
posts,
reply,
mode: (next: typeof mode) => {
mode = next;
},
deny: (status: number) => {
readStatus = status;
},
};
}
const input = () => screen.getByRole("textbox", { name: "Message to Research companion" });
const send = () => screen.getByRole("button", { name: "Send message" });
async function compose(text: string) {
await fireEvent.input(input(), { target: { value: text } });
await fireEvent.click(send());
}
async function ready() {
await screen.findByText("No public messages yet.");
}
async function refresh() {
await fireEvent.click(
screen.getByRole("button", { name: "Refresh history" }),
);
}
test("acceptance allows Busy follow-up without inventing a reply; multiple and delayed publications appear only from saved pages", async () => {
const h = harness();
render(SubjectConversation, props);
await ready();
await compose("first input while working");
await screen.findByText(
"Runtime accepted this input. Processing and replies are not confirmed.",
);
await compose("second input before any reply");
await waitFor(() => expect(h.posts).toHaveLength(2));
await waitFor(() => expect(readConversationAttempt(sessionStorage, props)).toBeNull());
expect(screen.queryByText("Explicit first reply")).toBeNull();
await waitFor(() =>
expect((screen.getByRole("button", { name: "Refresh history" }) as HTMLButtonElement).disabled)
.toBe(false)
);
await refresh();
await waitFor(() => expect(screen.getAllByText("You", { exact: true })).toHaveLength(2));
h.reply("Explicit first reply");
h.reply("Explicit second reply");
await refresh();
await screen.findByText("Explicit second reply");
h.reply("Later evaluated background result");
await fireEvent(window, new Event("online"));
await screen.findByText("Later evaluated background result");
expect(screen.queryByText("internal assistant output")).toBeNull();
expect(screen.getAllByText("Research companion", { exact: true }))
.toHaveLength(3);
});
test("ACK loss keeps full input across reload while the next draft stays editable; exact retry does not duplicate public rows", async () => {
const h = harness();
h.mode("lost");
const view = render(SubjectConversation, props);
await ready();
const original = " original input\n日本語 ";
await compose(original);
await screen.findByText(/request outcome could not be confirmed/i);
await fireEvent.input(input(), { target: { value: "edited next draft" } });
expect((send() as HTMLButtonElement).disabled).toBe(true);
expect(readConversationAttempt(sessionStorage, props)?.text).toBe(original);
view.unmount();
render(SubjectConversation, props);
await screen.findByText(
"Previous send outcome is unknown. Its original input is retained.",
);
await waitFor(() =>
expect(
(screen.getByRole("button", {
name: "Retry same input",
}) as HTMLButtonElement).disabled,
).toBe(false)
);
h.mode("accepted");
await fireEvent.click(
screen.getByRole("button", { name: "Retry same input" }),
);
await screen.findByText(
"Runtime accepted this input. Processing and replies are not confirmed.",
);
expect(h.posts).toHaveLength(2);
expect(h.posts[0]).toEqual(h.posts[1]);
expect(h.messages).toHaveLength(1);
await waitFor(() => expect(screen.getAllByText("You", { exact: true })).toHaveLength(1));
expect(readConversationAttempt(sessionStorage, props)).toBeNull();
});
for (
const mode of [
"no_current_body",
"unknown",
"rejected",
"conflict",
"failed",
] as const
) {
test(`${mode} retains original input and never shows an accepted or processed result`, async () => {
const h = harness();
h.mode(mode);
render(SubjectConversation, props);
await ready();
await compose("retained");
await waitFor(() =>
expect(
(screen.getByRole("button", {
name: "Retry same input",
}) as HTMLButtonElement).disabled,
).toBe(false)
);
expect(readConversationAttempt(sessionStorage, props)?.text).toBe(
"retained",
);
expect(
screen.queryByText(
"Runtime accepted this input. Processing and replies are not confirmed.",
),
).toBeNull();
await fireEvent.input(input(), { target: { value: "do not replace" } });
expect((send() as HTMLButtonElement).disabled).toBe(true);
await fireEvent.click(
screen.getByRole("button", { name: "Retry same input" }),
);
await waitFor(() => expect(h.posts).toHaveLength(2));
expect(h.posts[0]).toEqual(h.posts[1]);
});
}
test("storage write refusal dispatches nothing, preserves the draft and can be rechecked without sending", async () => {
const h = harness();
render(SubjectConversation, props);
await ready();
const originalStorage = sessionStorage;
vi.stubGlobal("sessionStorage", {
getItem: originalStorage.getItem.bind(originalStorage),
setItem: () => {
throw new Error("quota");
},
removeItem: originalStorage.removeItem.bind(originalStorage),
});
await compose("unsent draft");
await screen.findByText(
"Send input could not be retained safely. Nothing new was dispatched.",
);
expect(h.posts).toHaveLength(0);
expect((input() as HTMLTextAreaElement).value).toBe("unsent draft");
vi.stubGlobal("sessionStorage", originalStorage);
await fireEvent.click(
screen.getByRole("button", { name: "Recheck retry storage" }),
);
expect(h.posts).toHaveLength(0);
await fireEvent.click(send());
await screen.findByText(
"Runtime accepted this input. Processing and replies are not confirmed.",
);
});
test("accepted clear failure blocks new input; local recheck clears only the matching retry record without redelivery", async () => {
const h = harness();
render(SubjectConversation, props);
await ready();
const originalStorage = sessionStorage;
vi.stubGlobal("sessionStorage", {
getItem: originalStorage.getItem.bind(originalStorage),
setItem: originalStorage.setItem.bind(originalStorage),
removeItem: () => {
throw new Error("blocked");
},
});
await compose("accepted once");
await screen.findByText(/local retry record could not be cleared/);
expect(readConversationAttempt(sessionStorage, props)).not.toBeNull();
expect(h.posts).toHaveLength(1);
vi.stubGlobal("sessionStorage", originalStorage);
await fireEvent.click(
screen.getByRole("button", { name: "Recheck retry storage" }),
);
expect(readConversationAttempt(sessionStorage, props)).toBeNull();
expect(h.posts).toHaveLength(1);
});
test("more than one page follows saved sequence, reconnect adds only unseen messages, and permission loss removes composer/history", async () => {
const h = harness();
for (let i = 1; i <= 105; ++i) h.reply(`Public reply ${i}`);
render(SubjectConversation, props);
await screen.findByText("Public reply 100");
expect(screen.queryByText("Public reply 101")).toBeNull();
await fireEvent.click(
screen.getByRole("button", { name: "Load newer messages" }),
);
await screen.findByText("Public reply 105");
await fireEvent(window, new Event("online"));
await waitFor(() =>
expect(screen.getAllByText("Research companion", { exact: true }))
.toHaveLength(105)
);
h.deny(403);
await refresh();
await screen.findByText("You do not have access to this conversation.");
expect(screen.queryByRole("textbox")).toBeNull();
expect(screen.queryByText("Public reply 1", { exact: true })).toBeNull();
});
test("corrupt or mismatched stored input fails closed rather than silently issuing a new operation", async () => {
const h = harness();
sessionStorage.setItem(conversationAttemptKey(props), "corrupt");
render(SubjectConversation, props);
await ready();
await compose("must not send");
expect(h.posts).toHaveLength(0);
expect((send() as HTMLButtonElement).disabled).toBe(true);
expect(sessionStorage.getItem(conversationAttemptKey(props))).toBe("corrupt");
});
@@ -35,6 +35,19 @@
snapshot.kind === 'ready' && snapshot.value.session.kind === 'authenticated' && snapshot.value.actor?.account_id === context.scope.accountId; snapshot.kind === 'ready' && snapshot.value.session.kind === 'authenticated' && snapshot.value.actor?.account_id === context.scope.accountId;
} }
function withdrawAccess(context: NonNullable<typeof active>): void {
context.controller.abort();
inaccessible = true;
conversation = null;
messages = [];
attempt = null;
delivery = null;
draft = '';
sending = false;
reading = false;
storageBlocked = false;
}
$effect(() => { $effect(() => {
const scope: ConversationScope = { workspaceId, subjectId, accountId }; const scope: ConversationScope = { workspaceId, subjectId, accountId };
return untrack(() => { return untrack(() => {
@@ -62,10 +75,9 @@
document.addEventListener('visibilitychange', reconnect); document.addEventListener('visibilitychange', reconnect);
const unsubscribe = browserSession.subscribe(() => { const unsubscribe = browserSession.subscribe(() => {
if (!current(context)) { if (!current(context)) {
context.controller.abort(); withdrawAccess(context);
messages = []; sendError = null;
conversation = null; historyError = 'The conversation account changed or could not be confirmed. Reopen this page after confirming your session.';
inaccessible = true;
} }
}); });
return () => { return () => {
@@ -101,9 +113,7 @@
if (!current(context)) return; if (!current(context)) return;
historyError = error instanceof ConversationApiError ? error.message : 'Conversation history could not be loaded.'; historyError = error instanceof ConversationApiError ? error.message : 'Conversation history could not be loaded.';
if (error instanceof ConversationApiError && [401, 403, 404].includes(error.status ?? 0)) { if (error instanceof ConversationApiError && [401, 403, 404].includes(error.status ?? 0)) {
inaccessible = true; withdrawAccess(context);
conversation = null;
messages = [];
} }
} finally { if (current(context)) reading = false; } } finally { if (current(context)) reading = false; }
} }
@@ -140,9 +150,7 @@
if (!current(context)) return; if (!current(context)) return;
sendError = error instanceof ConversationApiError ? error.message : 'Send outcome unknown. Keep the saved request.'; sendError = error instanceof ConversationApiError ? error.message : 'Send outcome unknown. Keep the saved request.';
if (error instanceof ConversationApiError && [401, 403, 404].includes(error.status ?? 0)) { if (error instanceof ConversationApiError && [401, 403, 404].includes(error.status ?? 0)) {
inaccessible = true; withdrawAccess(context);
messages = [];
conversation = null;
} }
} finally { if (current(context)) sending = false; } } finally { if (current(context)) sending = false; }
} }
@@ -184,7 +192,7 @@
{#if !inaccessible}<button type="button" onclick={() => refresh()} disabled={reading}>{reading ? 'Loading history…' : 'Refresh history'}</button>{/if} {#if !inaccessible}<button type="button" onclick={() => refresh()} disabled={reading}>{reading ? 'Loading history…' : 'Refresh history'}</button>{/if}
</header> </header>
{#if historyError}<p class="error" role="alert">{historyError}</p>{/if} {#if historyError}<p class="error" role="alert">{historyError}</p>{/if}
{#if !conversation && !historyError}<p role="status">Loading conversation…</p>{/if} {#if !conversation && !historyError && !inaccessible}<p role="status">Loading conversation…</p>{/if}
<ol aria-label="Saved public messages"> <ol aria-label="Saved public messages">
{#each messages as message (message.id)} {#each messages as message (message.id)}
<li> <li>
@@ -117,13 +117,15 @@ Deno.test("exclusive sequence pages append without duplicating an earlier public
Deno.test("revisit uses the same account-scoped create input then reads authoritative pages", async () => { Deno.test("revisit uses the same account-scoped create input then reads authoritative pages", async () => {
const requests: { path: string; body: unknown }[] = []; const requests: { path: string; body: unknown }[] = [];
const fetchFn: typeof fetch = (input, init) => { const fetchFn: typeof fetch = (input, init) => {
const body = init && "body" in init ? init.body : null;
const method = init && "method" in init ? init.method : "GET";
requests.push({ requests.push({
path: String(input), path: String(input),
body: init?.body ? JSON.parse(String(init.body)) : null, body: body ? JSON.parse(String(body)) : null,
}); });
return Promise.resolve( return Promise.resolve(
Response.json( Response.json(
init?.method === "POST" ? conversation : page([message(1)]), method === "POST" ? conversation : page([message(2)]),
), ),
); );
}; };
@@ -152,7 +154,7 @@ Deno.test("ACK loss retains exact request bytes for explicit replay and accepted
}; };
const bodies: string[] = []; const bodies: string[] = [];
const fetchFn: typeof fetch = (_input, init) => { const fetchFn: typeof fetch = (_input, init) => {
bodies.push(String(init?.body)); bodies.push(String(init && "body" in init ? init.body : null));
return bodies.length === 1 return bodies.length === 1
? Promise.reject(new TypeError("ACK lost")) ? Promise.reject(new TypeError("ACK lost"))
: Promise.resolve( : Promise.resolve(
@@ -0,0 +1,188 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/svelte";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import SubjectConversation from "./SubjectConversation.svelte";
import { browserSession } from "#lib/workspace/auth/session-state.ts";
import { authUser } from "#lib/workspace/auth/api.test-fixtures.ts";
import { readConversationAttempt } from "./conversation-attempt.ts";
const props = {
workspaceId: "w",
subjectId: "s",
accountId: authUser.account_id,
subjectName: "Companion",
};
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
browserSession.snapshot = {
kind: "ready",
value: {
actor: { ...authUser, auth_method: "browser_session" },
authentication_reference: "fixture-session",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
},
};
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
sessionStorage.clear();
localStorage.clear();
});
function conversation(subject: string) {
return {
id: `c-${subject}`,
subject_id: subject,
participant_account_id: props.accountId,
last_user_message_id: null,
};
}
function page(subject: string, text: string) {
return {
conversation: conversation(subject),
messages: [{
id: `m-${subject}`,
conversation_id: `c-${subject}`,
sequence: 1,
sender: { kind: "subject", subject_id: subject },
text,
reply_to: null,
work_ref: null,
operation_id: "publish",
last_user_message_id: null,
}],
has_more: false,
};
}
for (const status of [401, 403, 404]) {
test(`${status} withdraws retained text and fences a late POST ACK without deleting its retry record`, async () => {
let release!: (response: Response) => void;
let posted = false;
let denied = false;
vi.stubGlobal(
"fetch",
vi.fn(async (input: string, init?: RequestInit) => {
if (String(input).endsWith("/conversations")) return Response.json(conversation("s"));
if (init?.method === "POST") {
posted = true;
return await new Promise<Response>((resolve) => {
release = resolve;
});
}
return denied
? new Response(null, { status })
: Response.json({ conversation: conversation("s"), messages: [], has_more: false });
}),
);
render(SubjectConversation, props);
await screen.findByText("No public messages yet.");
await fireEvent.input(screen.getByRole("textbox"), {
target: { value: "retained private text" },
});
await fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expect(posted).toBe(true));
const attempt = readConversationAttempt(sessionStorage, props)!;
denied = true;
await fireEvent.click(screen.getByRole("button", { name: "Refresh history" }));
await screen.findByRole("alert");
expect(screen.queryByText("retained private text")).toBeNull();
expect(screen.queryByRole("textbox")).toBeNull();
release(Response.json({
message: {
id: "user",
conversation_id: attempt.conversationId,
sequence: 1,
sender: { kind: "user", account_id: props.accountId },
text: attempt.text,
reply_to: null,
work_ref: null,
operation_id: attempt.operationId,
last_user_message_id: "user",
},
delivery: { state: "accepted", runtime_id: "r", worker_id: "w" },
}));
await Promise.resolve();
expect(readConversationAttempt(sessionStorage, props)).toEqual(attempt);
expect(
screen.queryByText("Runtime accepted this input. Processing and replies are not confirmed."),
).toBeNull();
expect(screen.queryByText("Loading conversation…")).toBeNull();
});
}
for (const changed of [{ subjectId: "other" }, { workspaceId: "other" }]) {
test(`late history cannot populate a different ${Object.keys(changed)[0]} route`, async () => {
let release!: (response: Response) => void;
let first = true;
let readingOld = false;
vi.stubGlobal(
"fetch",
vi.fn(async (_input: string, init?: RequestInit) => {
if (init?.method === "POST") {
return Response.json(conversation(JSON.parse(String(init.body)).subject_id));
}
if (first) {
first = false;
readingOld = true;
return await new Promise<Response>((resolve) => {
release = resolve;
});
}
return Response.json(page(changed.subjectId ?? "s", "current route public reply"));
}),
);
const view = render(SubjectConversation, props);
await waitFor(() => expect(readingOld).toBe(true));
await view.rerender({ ...props, ...changed });
await screen.findByText("current route public reply");
release(Response.json(page("s", "old route secret reply")));
await Promise.resolve();
expect(screen.queryByText("old route secret reply")).toBeNull();
});
}
test("authentication change while POST is in flight never clears the former Account's retained input or adopts its ACK", async () => {
let release!: (response: Response) => void;
let posted = false;
vi.stubGlobal(
"fetch",
vi.fn(async (input: string, init?: RequestInit) => {
if (String(input).endsWith("/conversations")) return Response.json(conversation("s"));
if (init?.method === "POST") {
posted = true;
return await new Promise<Response>((resolve) => {
release = resolve;
});
}
return Response.json({ conversation: conversation("s"), messages: [], has_more: false });
}),
);
render(SubjectConversation, props);
await screen.findByText("No public messages yet.");
await fireEvent.input(screen.getByRole("textbox"), { target: { value: "former account input" } });
await fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expect(posted).toBe(true));
const attempt = readConversationAttempt(sessionStorage, props)!;
browserSession.invalidate();
release(Response.json({
message: {
id: "user",
conversation_id: attempt.conversationId,
sequence: 1,
sender: { kind: "user", account_id: props.accountId },
text: attempt.text,
reply_to: null,
work_ref: null,
operation_id: attempt.operationId,
last_user_message_id: "user",
},
delivery: { state: "accepted", runtime_id: "r", worker_id: "w" },
}));
await Promise.resolve();
expect(readConversationAttempt(sessionStorage, props)).toEqual(attempt);
expect(
screen.queryByText("Runtime accepted this input. Processing and replies are not confirmed."),
).toBeNull();
expect(screen.queryByRole("textbox")).toBeNull();
expect(readConversationAttempt(sessionStorage, { ...props, accountId: "other" })).toBeNull();
});
@@ -180,7 +180,7 @@
{/if} {/if}
{#if !deletionStarted} {#if !deletionStarted}
<nav aria-label="Subjectの記録と管理"><a href={workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(data.subjectId)}/journal`)}>Journal · 最近の出来事とSessionを探す →</a> · <a href={workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(data.subjectId)}/permissions`)}>Operation permissions · 直接操作と委譲上限 →</a></nav> <nav aria-label="Subjectの記録と管理">{#if subject?.state === 'active'}<a href={workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(data.subjectId)}/conversation`)}>Open conversation</a> · {/if}<a href={workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(data.subjectId)}/journal`)}>Journal · 最近の出来事とSessionを探す →</a> · <a href={workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(data.subjectId)}/permissions`)}>Operation permissions · 直接操作と委譲上限 →</a></nav>
{#if subject} {#if subject}
<section class="subject-overview" aria-label="Subject status"> <section class="subject-overview" aria-label="Subject status">
@@ -0,0 +1,12 @@
<script lang="ts">
import DeferredPage from '#lib/workspace/navigation/DeferredPage.svelte';
import PageContent from './PageContent.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<DeferredPage {data} title="Subject conversation">
{#snippet children(resolved)}
<PageContent data={resolved} />
{/snippet}
</DeferredPage>
@@ -0,0 +1,26 @@
import { error } from "@sveltejs/kit";
import { browserSession } from "#lib/workspace/auth/session-state.ts";
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
import { MEMORY_API_LOAD_POLICY, parseSubjektivSubjectResponse } from "#lib/workspace/memory/api.ts";
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
import type { PageLoad } from "./$types";
export const load = deferPage(async ({ fetch, params }: Parameters<PageLoad>[0]) => {
const session = await browserSession.ensure();
if (session.kind !== "ready" || session.value.session.kind !== "authenticated" || !session.value.actor) {
error(503, "The conversation account could not be confirmed. Retry this page.");
}
const subject = await loadJson(
fetch,
workspaceApiPath(params.workspaceId, `/subjektiv/subjects/${encodeURIComponent(params.subjectId)}`),
undefined,
(value) => parseSubjektivSubjectResponse(value, params.subjectId),
MEMORY_API_LOAD_POLICY,
);
return {
workspaceId: params.workspaceId,
subjectId: params.subjectId,
accountId: session.value.actor.account_id,
subject,
};
});
@@ -0,0 +1,28 @@
<script lang="ts">
import SubjectConversation from '#lib/workspace/memory/SubjectConversation.svelte';
import { workspaceRoute } from '#lib/workspace/api/http.ts';
import type { ResolvedPageData } from '#lib/workspace/navigation/deferred.ts';
import type { PageData as RouteData } from './$types';
let { data }: { data: ResolvedPageData<RouteData> } = $props();
const subject = $derived(data.subject.data);
</script>
<svelte:head><title>{subject?.role ?? 'Subject'} · Conversation · Yoi Workspace</title></svelte:head>
<section class="conversation-page" aria-labelledby="conversation-subject-heading">
<a href={workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(data.subjectId)}`)}>← Subject details</a>
{#if subject}
<h1 id="conversation-subject-heading">{subject.role}</h1>
{#key JSON.stringify([data.accountId, data.workspaceId, subject.id])}
<SubjectConversation accountId={data.accountId} workspaceId={data.workspaceId} subjectId={subject.id} subjectName={subject.role} />
{/key}
{:else}
<h1 id="conversation-subject-heading">Subject conversation</h1>
<p role="alert">{data.subject.error ?? 'The Subject is unavailable. No conversation input was sent.'}</p>
{/if}
</section>
<style>
.conversation-page { display: grid; gap: var(--space-4); padding: var(--space-4); width: 100%; box-sizing: border-box; max-width: 960px; min-width: 0; margin-inline: auto; }
h1 { margin: 0; overflow-wrap: anywhere; }
</style>