diff --git a/crates/server-api/src/chat.rs b/crates/server-api/src/chat.rs index f2b4bcc7e..15fe9e825 100644 --- a/crates/server-api/src/chat.rs +++ b/crates/server-api/src/chat.rs @@ -170,8 +170,13 @@ mod tests { let normalize = |value: &str| { value .chars() - .filter(|c| !c.is_whitespace()) + .filter_map(|c| match c { + c if c.is_whitespace() => None, + ',' => Some(';'), + c => Some(c), + }) .collect::() + .replace(";}", "}") }; assert_eq!(normalize(&actual), normalize(&chat_api_typescript())); } diff --git a/crates/workspace-server/src/server/tests/chat_tests.rs b/crates/workspace-server/src/server/tests/chat_tests.rs index b975329dc..1fed35084 100644 --- a/crates/workspace-server/src/server/tests/chat_tests.rs +++ b/crates/workspace-server/src/server/tests/chat_tests.rs @@ -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 /// installed Feature, signed Runtime request transport, Backend and SQLite. #[derive(Clone)] diff --git a/tools/web-ux/browser-tests/subject_conversation_review.ts b/tools/web-ux/browser-tests/subject_conversation_review.ts new file mode 100644 index 000000000..ad096edd1 --- /dev/null +++ b/tools/web-ux/browser-tests/subject_conversation_review.ts @@ -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 ", + ); +} +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(); + const navigationRequests = new WeakSet(); + const pendingRequests = new Set(); + 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[] = []; + const requests: Record[] = []; + 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")); + } +} diff --git a/web/workspace/src/lib/generated/chat-api.ts b/web/workspace/src/lib/generated/chat-api.ts new file mode 100644 index 000000000..1ff9763a1 --- /dev/null +++ b/web/workspace/src/lib/generated/chat-api.ts @@ -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; + 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; +}; diff --git a/web/workspace/src/lib/workspace/memory/SubjectConversation.browser.spec.ts b/web/workspace/src/lib/workspace/memory/SubjectConversation.browser.spec.ts new file mode 100644 index 000000000..f196146be --- /dev/null +++ b/web/workspace/src/lib/workspace/memory/SubjectConversation.browser.spec.ts @@ -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"); +}); diff --git a/web/workspace/src/lib/workspace/memory/SubjectConversation.svelte b/web/workspace/src/lib/workspace/memory/SubjectConversation.svelte index b21acdb7d..861760309 100644 --- a/web/workspace/src/lib/workspace/memory/SubjectConversation.svelte +++ b/web/workspace/src/lib/workspace/memory/SubjectConversation.svelte @@ -35,6 +35,19 @@ snapshot.kind === 'ready' && snapshot.value.session.kind === 'authenticated' && snapshot.value.actor?.account_id === context.scope.accountId; } + function withdrawAccess(context: NonNullable): void { + context.controller.abort(); + inaccessible = true; + conversation = null; + messages = []; + attempt = null; + delivery = null; + draft = ''; + sending = false; + reading = false; + storageBlocked = false; + } + $effect(() => { const scope: ConversationScope = { workspaceId, subjectId, accountId }; return untrack(() => { @@ -62,10 +75,9 @@ document.addEventListener('visibilitychange', reconnect); const unsubscribe = browserSession.subscribe(() => { if (!current(context)) { - context.controller.abort(); - messages = []; - conversation = null; - inaccessible = true; + withdrawAccess(context); + sendError = null; + historyError = 'The conversation account changed or could not be confirmed. Reopen this page after confirming your session.'; } }); return () => { @@ -101,9 +113,7 @@ if (!current(context)) return; historyError = error instanceof ConversationApiError ? error.message : 'Conversation history could not be loaded.'; if (error instanceof ConversationApiError && [401, 403, 404].includes(error.status ?? 0)) { - inaccessible = true; - conversation = null; - messages = []; + withdrawAccess(context); } } finally { if (current(context)) reading = false; } } @@ -140,9 +150,7 @@ if (!current(context)) return; sendError = error instanceof ConversationApiError ? error.message : 'Send outcome unknown. Keep the saved request.'; if (error instanceof ConversationApiError && [401, 403, 404].includes(error.status ?? 0)) { - inaccessible = true; - messages = []; - conversation = null; + withdrawAccess(context); } } finally { if (current(context)) sending = false; } } @@ -184,7 +192,7 @@ {#if !inaccessible}{/if} {#if historyError}{/if} - {#if !conversation && !historyError}

Loading conversation…

{/if} + {#if !conversation && !historyError && !inaccessible}

Loading conversation…

{/if}
    {#each messages as message (message.id)}
  1. diff --git a/web/workspace/src/lib/workspace/memory/conversation-api.test.ts b/web/workspace/src/lib/workspace/memory/conversation-api.test.ts index ed8966545..76400b932 100644 --- a/web/workspace/src/lib/workspace/memory/conversation-api.test.ts +++ b/web/workspace/src/lib/workspace/memory/conversation-api.test.ts @@ -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 () => { const requests: { path: string; body: unknown }[] = []; 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({ path: String(input), - body: init?.body ? JSON.parse(String(init.body)) : null, + body: body ? JSON.parse(String(body)) : null, }); return Promise.resolve( 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 fetchFn: typeof fetch = (_input, init) => { - bodies.push(String(init?.body)); + bodies.push(String(init && "body" in init ? init.body : null)); return bodies.length === 1 ? Promise.reject(new TypeError("ACK lost")) : Promise.resolve( diff --git a/web/workspace/src/lib/workspace/memory/conversation-route-fence.browser.spec.ts b/web/workspace/src/lib/workspace/memory/conversation-route-fence.browser.spec.ts new file mode 100644 index 000000000..05e2b11d5 --- /dev/null +++ b/web/workspace/src/lib/workspace/memory/conversation-route-fence.browser.spec.ts @@ -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((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((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((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(); +}); diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/PageContent.svelte b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/PageContent.svelte index 1a9c0f8a1..171cede8f 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/PageContent.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/PageContent.svelte @@ -180,7 +180,7 @@ {/if} {#if !deletionStarted} - + {#if subject}
    diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/conversation/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/conversation/+page.svelte new file mode 100644 index 000000000..74dc0cd62 --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/conversation/+page.svelte @@ -0,0 +1,12 @@ + + + + {#snippet children(resolved)} + + {/snippet} + diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/conversation/+page.ts b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/conversation/+page.ts new file mode 100644 index 000000000..89f177fb2 --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/conversation/+page.ts @@ -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[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, + }; +}); diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/conversation/PageContent.svelte b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/conversation/PageContent.svelte new file mode 100644 index 000000000..7d9bb323b --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/memory/[subjectId]/conversation/PageContent.svelte @@ -0,0 +1,28 @@ + + +{subject?.role ?? 'Subject'} · Conversation · Yoi Workspace + +
    + ← Subject details + {#if subject} +

    {subject.role}

    + {#key JSON.stringify([data.accountId, data.workspaceId, subject.id])} + + {/key} + {:else} +

    Subject conversation

    +

    {data.subject.error ?? 'The Subject is unavailable. No conversation input was sent.'}

    + {/if} +
    + +