feat: connect Subject conversation route and validate exact retries
This commit is contained in:
@@ -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::<String>()
|
||||
.replace(";}", "}")
|
||||
};
|
||||
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
|
||||
/// installed Feature, signed Runtime request transport, Backend and SQLite.
|
||||
#[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;
|
||||
}
|
||||
|
||||
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(() => {
|
||||
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}<button type="button" onclick={() => refresh()} disabled={reading}>{reading ? 'Loading history…' : 'Refresh history'}</button>{/if}
|
||||
</header>
|
||||
{#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">
|
||||
{#each messages as message (message.id)}
|
||||
<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 () => {
|
||||
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(
|
||||
|
||||
@@ -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 !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}
|
||||
<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,
|
||||
};
|
||||
});
|
||||
+28
@@ -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>
|
||||
Reference in New Issue
Block a user