diff --git a/crates/server-api/Cargo.toml b/crates/server-api/Cargo.toml index 75daa75fc..ba51f5424 100644 --- a/crates/server-api/Cargo.toml +++ b/crates/server-api/Cargo.toml @@ -43,6 +43,10 @@ name = "generate_worker_launch_api_types" [[example]] name = "generate_companion_api_types" +[[example]] +name = "generate_chat_api_types" +required-features = ["typescript"] + [[example]] name = "generate_memory_api_types" required-features = ["typescript"] diff --git a/crates/server-api/examples/generate_chat_api_types.rs b/crates/server-api/examples/generate_chat_api_types.rs new file mode 100644 index 000000000..3b11df745 --- /dev/null +++ b/crates/server-api/examples/generate_chat_api_types.rs @@ -0,0 +1,6 @@ +fn main() { + print!( + "{}", + server_api::normalized_typescript(server_api::chat_api_typescript()) + ); +} diff --git a/crates/server-api/src/chat.rs b/crates/server-api/src/chat.rs index cfff0a836..f2b4bcc7e 100644 --- a/crates/server-api/src/chat.rs +++ b/crates/server-api/src/chat.rs @@ -129,11 +129,53 @@ pub struct ChatMessagePage { pub has_more: bool, } +#[cfg(feature = "typescript")] +pub fn chat_api_typescript() -> String { + use crate::{ChatDelivery, ChatReceiveResponse}; + use ts_rs::TS; + + let config = ts_rs::Config::default(); + let declarations = [ + ChatConversation::decl(&config), + ChatMessageSender::decl(&config), + ChatMessage::decl(&config), + ChatCreateConversationRequest::decl(&config), + ChatUserMessageRequest::decl(&config), + ChatMessagePageQuery::decl(&config), + ChatMessagePage::decl(&config), + ChatDelivery::decl(&config), + ChatReceiveResponse::decl(&config), + ] + .into_iter() + .map(|declaration| format!("export {declaration}")) + .collect::>() + .join("\n\n"); + format!( + "// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_chat_api_types > web/workspace/src/lib/generated/chat-api.ts\n\nexport const CHAT_LIMITS = {{ maxTextBytes: {CHAT_MAX_TEXT_BYTES}, maxPageLimit: {CHAT_MAX_PAGE_LIMIT}, maxCounter: {CHAT_MAX_COUNTER} }} as const;\n\n{declarations}\n" + ) +} + #[cfg(test)] mod tests { use super::*; use serde_json::json; + #[cfg(feature = "typescript")] + #[test] + fn generated_chat_browser_contract_is_current() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../web/workspace/src/lib/generated/chat-api.ts"); + let actual = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); + let normalize = |value: &str| { + value + .chars() + .filter(|c| !c.is_whitespace()) + .collect::() + }; + assert_eq!(normalize(&actual), normalize(&chat_api_typescript())); + } + #[test] fn text_only_requests_reject_attachments_and_claimed_sender() { for field in ["attachments", "sender", "subject_id", "account_id"] { diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 26070d9ba..b56315e6c 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@8.3.2 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts src/lib/workspace/memory/permissions.test.ts tests/subject-delete.test.ts tests/journal-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts", + "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts src/lib/workspace/memory/permissions.test.ts src/lib/workspace/memory/conversation-attempt.test.ts src/lib/workspace/memory/conversation-api.test.ts tests/subject-delete.test.ts tests/journal-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts", "test:journal:backend-contract": "cd ../.. && mkdir -p target && cargo test -p yoi-workspace-server --lib journal_browser_web_contract -- --nocapture > target/journal-browser-contract.log && cd web/workspace && deno run --allow-read=../../target/journal-browser-contract.log test/journal-backend-contract.ts ../../target/journal-browser-contract.log", "test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts", "test:browser:activation": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/activation-browser/activation-shell.browser.test.ts", diff --git a/web/workspace/src/lib/workspace/memory/SubjectConversation.svelte b/web/workspace/src/lib/workspace/memory/SubjectConversation.svelte new file mode 100644 index 000000000..b21acdb7d --- /dev/null +++ b/web/workspace/src/lib/workspace/memory/SubjectConversation.svelte @@ -0,0 +1,235 @@ + + +
+
+

Conversation

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

Loading conversation…

{/if} +
    + {#each messages as message (message.id)} +
  1. + {message.sender.kind === 'user' ? 'You' : subjectName} +

    {message.text}

    +
  2. + {/each} +
+ {#if conversation && messages.length === 0}

No public messages yet.

{/if} + {#if hasMore && !inaccessible}{/if} + +
+ {#if sending}

Sending saved input…

+ {:else if delivery}

{deliveryLabel(delivery)}

+ {:else if attempt}

Previous send outcome is unknown. Its original input is retained.

{/if} +
+ {#if sendError}{/if} + {#if storageBlocked}{/if} + {#if attempt} +
Retained send input

{attempt.text}

+

Retry uses the same ID and full input. Runtime duplicate suppression has bounded receipt retention; it is not an indefinite delivery guarantee.

+ {#if !inaccessible && !storageBlocked}{/if} + {/if} + {#if !inaccessible} +
{ event.preventDefault(); void send(); }}> + + +

Text only, up to {CHAT_LIMITS.maxTextBytes} UTF-8 bytes. Attachments are not supported.

+ {#if draftBytes > CHAT_LIMITS.maxTextBytes}

The message exceeds the text limit.

{/if} + {#if attempt}

Resolve the retained send before sending another message. Your next draft remains editable.

{/if} + +
+ {/if} +
+ + diff --git a/web/workspace/src/lib/workspace/memory/conversation-api.test.ts b/web/workspace/src/lib/workspace/memory/conversation-api.test.ts new file mode 100644 index 000000000..ed8966545 --- /dev/null +++ b/web/workspace/src/lib/workspace/memory/conversation-api.test.ts @@ -0,0 +1,207 @@ +// HTTP境界のfixture試験。実Backend/Runtime接続の証拠ではない。 +import { + appendConversationMessages, + ConversationApiError, + getConversationPage, + openSubjectConversation, + parseConversationMessage, + parseConversationPage, + sendConversationInput, +} from "./conversation-api.ts"; +import { CHAT_LIMITS } from "#lib/generated/chat-api.ts"; +import { + readConversationAttempt, + saveConversationAttempt, +} from "./conversation-attempt.ts"; + +declare const Deno: { + test(name: string, fn: () => void | Promise): void; +}; +function assert(value: unknown): asserts value { + if (!value) throw new Error("Assertion failed"); +} +function rejects(fn: () => unknown) { + let failed = false; + try { + fn(); + } catch { + failed = true; + } + assert(failed); +} +const scope = { + workspaceId: "w/1", + subjectId: "subject", + accountId: "account", +}; +const conversation = { + id: "c/1", + subject_id: scope.subjectId, + participant_account_id: scope.accountId, + last_user_message_id: null, +}; +const attempt = { + conversationId: conversation.id, + operationId: "send-id", + text: " exact\n本文 ", + replyTo: null, + workRef: null, +}; +function message(sequence = 1) { + return { + id: `m${sequence}`, + conversation_id: conversation.id, + sequence, + sender: { kind: "user", account_id: scope.accountId }, + text: attempt.text, + reply_to: null, + work_ref: null, + operation_id: attempt.operationId, + last_user_message_id: `m${sequence}`, + }; +} +function page(messages: unknown[], has_more = false) { + return { conversation, messages, has_more }; +} + +Deno.test("public conversation boundary rejects wrong identity internal senders and unsafe cursor/order", () => { + for ( + const invalid of [ + { ...message(), conversation_id: "other" }, + { ...message(), sender: { kind: "assistant" } }, + { ...message(), sender: { kind: "tool" } }, + { ...message(), sender: { kind: "subject", subject_id: "other" } }, + { ...message(), sender: { kind: "user", account_id: "other" } }, + { ...message(), sequence: Number.MAX_SAFE_INTEGER + 1 }, + { ...message(), sequence: 0 }, + { ...message(), attachments: [] }, + ] + ) rejects(() => parseConversationMessage(invalid, scope, conversation.id)); + for ( + const invalid of [ + page([message(2), message(1)]), + page([message(1), message(1)]), + page([], true), + { ...page([]), conversation: { ...conversation, subject_id: "other" } }, + { + ...page([]), + conversation: { ...conversation, participant_account_id: "other" }, + }, + ] + ) rejects(() => parseConversationPage(invalid, scope, conversation.id, 0)); + rejects(() => + parseConversationPage(page([message(1)]), scope, conversation.id, 1) + ); +}); + +Deno.test("exclusive sequence pages append without duplicating an earlier public message", () => { + const first = parseConversationPage( + page([message(1), message(2)], true), + scope, + conversation.id, + 0, + ); + const second = parseConversationPage( + page([message(3)]), + scope, + conversation.id, + 2, + ); + const merged = appendConversationMessages(first.messages, second.messages); + assert(merged.map((m) => m.sequence).join() === "1,2,3"); + rejects(() => appendConversationMessages(merged, [merged[0]])); + const repeat = { ...second.messages[0], sequence: 4 }; + rejects(() => appendConversationMessages(merged, [repeat])); +}); + +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) => { + requests.push({ + path: String(input), + body: init?.body ? JSON.parse(String(init.body)) : null, + }); + return Promise.resolve( + Response.json( + init?.method === "POST" ? conversation : page([message(1)]), + ), + ); + }; + const signal = new AbortController().signal; + const first = await openSubjectConversation(fetchFn, scope, signal); + const revisit = await openSubjectConversation(fetchFn, scope, signal); + assert(first.id === revisit.id); + assert(JSON.stringify(requests[0]) === JSON.stringify(requests[1])); + await getConversationPage(fetchFn, scope, first.id, 1, signal); + assert( + requests[2].path === + `/api/w/w%2F1/chat/conversations/c%2F1/messages?after_sequence=1&limit=${CHAT_LIMITS.maxPageLimit}`, + ); +}); + +Deno.test("ACK loss retains exact request bytes for explicit replay and accepted is not a reply", async () => { + const saved = new Map(); + const storage = { + getItem: (k: string) => saved.get(k) ?? null, + setItem: (k: string, v: string) => { + saved.set(k, v); + }, + removeItem: (k: string) => { + saved.delete(k); + }, + }; + const bodies: string[] = []; + const fetchFn: typeof fetch = (_input, init) => { + bodies.push(String(init?.body)); + return bodies.length === 1 + ? Promise.reject(new TypeError("ACK lost")) + : Promise.resolve( + Response.json({ + message: message(), + delivery: { state: "accepted", runtime_id: "r", worker_id: "w" }, + }), + ); + }; + const signal = new AbortController().signal; + saveConversationAttempt(storage, scope, attempt); + try { + await sendConversationInput(fetchFn, scope, attempt, signal); + throw new Error("Expected unknown"); + } catch (error) { + assert(error instanceof ConversationApiError && error.status === null); + } + const retained = readConversationAttempt(storage, scope); + assert(retained !== null); + const receipt = await sendConversationInput(fetchFn, scope, retained, signal); + assert(bodies[0] === bodies[1]); + assert(receipt.message.sender.kind === "user"); + assert(receipt.delivery.state === "accepted"); +}); + +Deno.test("HTTP rejection and saved-message delivery failure never become an accepted reply", async () => { + const signal = new AbortController().signal; + for (const status of [401, 403, 404, 409, 500]) { + const fetchFn: typeof fetch = () => + Promise.resolve(new Response(null, { status })); + try { + await sendConversationInput(fetchFn, scope, attempt, signal); + throw new Error("Expected rejection"); + } catch (error) { + assert(error instanceof ConversationApiError && error.status === status); + } + } + for ( + const delivery of [ + { state: "no_current_body" }, + { state: "rejected", runtime_id: "r", worker_id: "w", detail: "stopped" }, + { state: "unknown", runtime_id: "r", worker_id: "w", detail: "lost" }, + { state: "unavailable", detail: "unavailable" }, + ] + ) { + const fetchFn: typeof fetch = () => + Promise.resolve(Response.json({ message: message(), delivery })); + const result = await sendConversationInput(fetchFn, scope, attempt, signal); + assert(result.delivery.state === delivery.state); + assert(result.message.sender.kind === "user"); + } +}); diff --git a/web/workspace/src/lib/workspace/memory/conversation-api.ts b/web/workspace/src/lib/workspace/memory/conversation-api.ts new file mode 100644 index 000000000..fb6fcdf37 --- /dev/null +++ b/web/workspace/src/lib/workspace/memory/conversation-api.ts @@ -0,0 +1,304 @@ +import { CHAT_LIMITS } from "#lib/generated/chat-api.ts"; +import type { + ChatConversation, + ChatCreateConversationRequest, + ChatDelivery, + ChatMessage, + ChatMessagePage, + ChatReceiveResponse, + ChatUserMessageRequest, +} from "#lib/generated/chat-api.ts"; +import { readBoundedJson, workspaceApiPath } from "#lib/workspace/api/http.ts"; +import { + type ConversationAttempt, + conversationOperationId, + type ConversationScope, +} from "./conversation-attempt.ts"; + +export class ConversationApiError extends Error { + constructor(readonly status: number | null) { + super( + status === 401 || status === 403 + ? "You do not have access to this conversation." + : status === 404 + ? "The Subject or conversation is no longer available." + : status === 409 + ? "The saved operation conflicts with another input. Keep the original request; do not send it under a new ID." + : "The request outcome could not be confirmed. Keep the saved input and retry the same request.", + ); + } +} + +function invalid(): never { + throw new ConversationApiError(null); +} +function object(value: unknown, keys: string[]): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + invalid(); + } + const record = value as Record; + if ( + Object.keys(record).length !== keys.length || + keys.some((key) => !(key in record)) + ) invalid(); + return record; +} +function text(value: unknown, maxBytes = 512): string { + if ( + typeof value !== "string" || !value.trim() || + new TextEncoder().encode(value).length > maxBytes + ) invalid(); + return value; +} +function reference(value: unknown): string | null { + return value === null ? null : text(value); +} + +export function parseConversation( + value: unknown, + scope: ConversationScope, +): ChatConversation { + const v = object(value, [ + "id", + "subject_id", + "participant_account_id", + "last_user_message_id", + ]); + if ( + v.subject_id !== scope.subjectId || + v.participant_account_id !== scope.accountId + ) invalid(); + return { + id: text(v.id), + subject_id: scope.subjectId, + participant_account_id: scope.accountId, + last_user_message_id: reference(v.last_user_message_id), + }; +} + +export function parseConversationMessage( + value: unknown, + scope: ConversationScope, + conversationId: string, +): ChatMessage { + const v = object(value, [ + "id", + "conversation_id", + "sequence", + "sender", + "text", + "reply_to", + "work_ref", + "operation_id", + "last_user_message_id", + ]); + if ( + v.conversation_id !== conversationId || typeof v.sequence !== "number" || + !Number.isSafeInteger(v.sequence) || v.sequence < 1 || + v.sequence > CHAT_LIMITS.maxCounter + ) invalid(); + if (typeof v.sender !== "object" || v.sender === null) invalid(); + const kind = (v.sender as Record).kind; + let sender: ChatMessage["sender"]; + if (kind === "user") { + const s = object(v.sender, ["kind", "account_id"]); + if (s.account_id !== scope.accountId) invalid(); + sender = { kind, account_id: scope.accountId }; + } else if (kind === "subject") { + const s = object(v.sender, ["kind", "subject_id"]); + if (s.subject_id !== scope.subjectId) invalid(); + sender = { kind, subject_id: scope.subjectId }; + } else invalid(); + return { + id: text(v.id), + conversation_id: conversationId, + sequence: v.sequence, + sender, + text: text(v.text, CHAT_LIMITS.maxTextBytes), + reply_to: reference(v.reply_to), + work_ref: reference(v.work_ref), + operation_id: text(v.operation_id), + last_user_message_id: reference(v.last_user_message_id), + }; +} + +export function parseConversationPage( + value: unknown, + scope: ConversationScope, + conversationId: string, + after: number, +): ChatMessagePage { + const v = object(value, ["conversation", "messages", "has_more"]); + const conversation = parseConversation(v.conversation, scope); + if ( + conversation.id !== conversationId || !Array.isArray(v.messages) || + v.messages.length > CHAT_LIMITS.maxPageLimit || + typeof v.has_more !== "boolean" + ) invalid(); + const messages = v.messages.map((message) => + parseConversationMessage(message, scope, conversationId) + ); + let previous = after; + const ids = new Set(); + for (const message of messages) { + if (message.sequence <= previous || ids.has(message.id)) invalid(); + previous = message.sequence; + ids.add(message.id); + } + if (v.has_more && messages.length === 0) invalid(); + return { conversation, messages, has_more: v.has_more }; +} + +export function appendConversationMessages( + saved: ChatMessage[], + incoming: ChatMessage[], +): ChatMessage[] { + let previous = saved.at(-1)?.sequence ?? 0; + const ids = new Set(saved.map((message) => message.id)); + for (const message of incoming) { + if (message.sequence <= previous || ids.has(message.id)) invalid(); + previous = message.sequence; + ids.add(message.id); + } + return [...saved, ...incoming]; +} + +function parseDelivery(value: unknown): ChatDelivery { + if (typeof value !== "object" || value === null) invalid(); + const state = (value as Record).state; + if (state === "no_current_body") { + object(value, ["state"]); + return { state }; + } + if (state === "unavailable") { + const v = object(value, ["state", "detail"]); + return { + state, + detail: typeof v.detail === "string" ? v.detail : invalid(), + }; + } + if (state !== "accepted" && state !== "rejected" && state !== "unknown") { + invalid(); + } + const v = object( + value, + state === "accepted" + ? ["state", "runtime_id", "worker_id"] + : ["state", "runtime_id", "worker_id", "detail"], + ); + const target = { + runtime_id: text(v.runtime_id), + worker_id: text(v.worker_id), + }; + if (state === "accepted") return { state, ...target }; + return { + state, + ...target, + detail: typeof v.detail === "string" ? v.detail : invalid(), + }; +} + +async function request( + fetchFn: typeof fetch, + path: string, + signal: AbortSignal, + body?: unknown, +): Promise { + let response: Response; + try { + response = await fetchFn(path, { + signal, + credentials: "same-origin", + cache: "no-store", + ...(body === undefined ? {} : { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + }); + } catch { + throw new ConversationApiError(null); + } + if (!response.ok) { + await response.body?.cancel(); + throw new ConversationApiError(response.status); + } + try { + return await readBoundedJson(response, 2 * 1024 * 1024); + } catch { + throw new ConversationApiError(null); + } +} + +function base(scope: ConversationScope): string { + return workspaceApiPath(scope.workspaceId, "/chat/conversations"); +} +export async function openSubjectConversation( + fetchFn: typeof fetch, + scope: ConversationScope, + signal: AbortSignal, +): Promise { + const body: ChatCreateConversationRequest = { + operation_id: conversationOperationId(scope.subjectId), + subject_id: scope.subjectId, + }; + return parseConversation( + await request(fetchFn, base(scope), signal, body), + scope, + ); +} +export async function getConversationPage( + fetchFn: typeof fetch, + scope: ConversationScope, + conversationId: string, + after: number, + signal: AbortSignal, +): Promise { + const query = new URLSearchParams({ + after_sequence: String(after), + limit: String(CHAT_LIMITS.maxPageLimit), + }); + return parseConversationPage( + await request( + fetchFn, + `${base(scope)}/${encodeURIComponent(conversationId)}/messages?${query}`, + signal, + ), + scope, + conversationId, + after, + ); +} +export async function sendConversationInput( + fetchFn: typeof fetch, + scope: ConversationScope, + attempt: ConversationAttempt, + signal: AbortSignal, +): Promise { + const body: ChatUserMessageRequest = { + operation_id: attempt.operationId, + text: attempt.text, + reply_to: attempt.replyTo, + work_ref: attempt.workRef, + }; + const v = object( + await request( + fetchFn, + `${base(scope)}/${encodeURIComponent(attempt.conversationId)}/messages`, + signal, + body, + ), + ["message", "delivery"], + ); + const message = parseConversationMessage( + v.message, + scope, + attempt.conversationId, + ); + if ( + message.sender.kind !== "user" || + message.operation_id !== body.operation_id || message.text !== body.text || + message.reply_to !== body.reply_to || message.work_ref !== body.work_ref + ) invalid(); + return { message, delivery: parseDelivery(v.delivery) }; +} diff --git a/web/workspace/src/lib/workspace/memory/conversation-attempt.test.ts b/web/workspace/src/lib/workspace/memory/conversation-attempt.test.ts new file mode 100644 index 000000000..3f2345e53 --- /dev/null +++ b/web/workspace/src/lib/workspace/memory/conversation-attempt.test.ts @@ -0,0 +1,194 @@ +import { + clearConversationAttempt, + type ConversationAttempt, + conversationAttemptKey, + conversationOperationId, + type ConversationScope, + readConversationAttempt, + saveConversationAttempt, +} from "./conversation-attempt.ts"; + +declare const Deno: { test(name: string, fn: () => void): void }; +function assert(condition: unknown): asserts condition { + if (!condition) throw new Error("Assertion failed"); +} +function rejects(fn: () => void): void { + let threw = false; + try { + fn(); + } catch { + threw = true; + } + assert(threw); +} +function storage() { + const values = new Map(); + return { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { + values.set(key, value); + }, + removeItem: (key: string) => { + values.delete(key); + }, + }; +} +const scope: ConversationScope = { + workspaceId: "workspace", + subjectId: "subject", + accountId: "account", +}; +const attempt: ConversationAttempt = { + conversationId: "conversation", + operationId: "operation", + text: " original\n本文 ", + replyTo: null, + workRef: null, +}; + +Deno.test("reload and explicit retry preserve the entire original send, not the edited draft", () => { + const s = storage(); + saveConversationAttempt(s, scope, attempt); + const restored = readConversationAttempt(s, scope); + assert(JSON.stringify(restored) === JSON.stringify(attempt)); + assert(Object.isFrozen(restored)); + saveConversationAttempt(s, scope, { ...attempt }); + assert(readConversationAttempt(s, scope)?.text === " original\n本文 "); + rejects(() => + saveConversationAttempt(s, scope, { ...attempt, text: "edited" }) + ); + assert(readConversationAttempt(s, scope)?.text === attempt.text); +}); + +Deno.test("unresolved input cannot be overwritten by a new ID or changed reply/work/conversation", () => { + const s = storage(); + saveConversationAttempt(s, scope, attempt); + for ( + const changed of [ + { ...attempt, operationId: "new" }, + { ...attempt, conversationId: "other" }, + { ...attempt, replyTo: "reply" }, + { ...attempt, workRef: "work" }, + ] + ) { + rejects(() => saveConversationAttempt(s, scope, changed)); + rejects(() => clearConversationAttempt(s, scope, changed)); + assert( + readConversationAttempt(s, scope)?.operationId === attempt.operationId, + ); + } + clearConversationAttempt(s, scope, { ...attempt }); + assert(readConversationAttempt(s, scope) === null); +}); + +Deno.test("Workspace Subject and authenticated Account have separate retained input", () => { + const s = storage(); + saveConversationAttempt(s, scope, attempt); + for ( + const other of [ + { ...scope, workspaceId: "other" }, + { ...scope, subjectId: "other" }, + { ...scope, accountId: "other" }, + ] + ) { + assert(readConversationAttempt(s, other) === null); + // 区切り文字を含む識別子でも別scopeに衝突しない。 + assert(conversationAttemptKey(other) !== conversationAttemptKey(scope)); + } + assert( + conversationAttemptKey({ + workspaceId: "a:b", + subjectId: "c", + accountId: "d", + }) !== + conversationAttemptKey({ + workspaceId: "a", + subjectId: "b:c", + accountId: "d", + }), + ); + assert( + conversationOperationId(scope.subjectId) === + conversationOperationId(scope.subjectId), + ); + assert( + conversationOperationId("other") !== + conversationOperationId(scope.subjectId), + ); +}); + +Deno.test("corrupt or cross-scope retained input fails closed without replacement or clearing", () => { + const s = storage(); + const key = conversationAttemptKey(scope); + for ( + const bad of [ + "not json", + JSON.stringify({ + version: 1, + scope: { ...scope, accountId: "other" }, + attempt, + }), + JSON.stringify({ + version: 1, + scope, + attempt: { ...attempt, operationId: "" }, + }), + JSON.stringify({ + version: 1, + scope, + attempt: { ...attempt, replyTo: undefined }, + }), + JSON.stringify({ + version: 1, + scope, + attempt: { ...attempt, attachments: [] }, + }), + ] + ) { + s.setItem(key, bad); + rejects(() => readConversationAttempt(s, scope)); + rejects(() => saveConversationAttempt(s, scope, attempt)); + rejects(() => clearConversationAttempt(s, scope, attempt)); + assert(s.getItem(key) === bad); + } +}); + +Deno.test("storage refusal is not a retained send or successful clear", () => { + const s = storage(); + rejects(() => + saveConversationAttempt( + { + ...s, + setItem: () => { + throw new Error("quota"); + }, + }, + scope, + attempt, + ) + ); + assert(readConversationAttempt(s, scope) === null); + saveConversationAttempt(s, scope, attempt); + rejects(() => + clearConversationAttempt( + { + ...s, + removeItem: () => { + throw new Error("blocked"); + }, + }, + scope, + attempt, + ) + ); + assert( + readConversationAttempt(s, scope)?.operationId === attempt.operationId, + ); + rejects(() => + readConversationAttempt({ + getItem: () => { + throw new Error("blocked"); + }, + }, scope) + ); +}); diff --git a/web/workspace/src/lib/workspace/memory/conversation-attempt.ts b/web/workspace/src/lib/workspace/memory/conversation-attempt.ts new file mode 100644 index 000000000..945341b12 --- /dev/null +++ b/web/workspace/src/lib/workspace/memory/conversation-attempt.ts @@ -0,0 +1,127 @@ +// 会話の正本や配送queueではなく、同じtabでの明示再送に必要な全入力だけを保持する。 +// 送信結果が不明な間は次の要求で上書きせず、本文の後続編集とは分離する。 +export type ConversationScope = Readonly<{ + workspaceId: string; + subjectId: string; + accountId: string; +}>; + +export type ConversationAttempt = Readonly<{ + conversationId: string; + operationId: string; + text: string; + replyTo: string | null; + workRef: string | null; +}>; + +type AttemptStorage = Pick; + +export function conversationOperationId(subjectId: string): string { + // createのoperationはAccount/Workspaceに束縛される。再訪・別tabでも同じ会話を解決する。 + return `web-subject-conversation/v1/${subjectId}`; +} + +export function conversationAttemptKey(scope: ConversationScope): string { + return `yoi.conversation-attempt:${ + JSON.stringify([scope.workspaceId, scope.subjectId, scope.accountId]) + }`; +} + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function exact(value: Record, keys: string[]): boolean { + return Object.keys(value).length === keys.length && + keys.every((key) => key in value); +} + +function id(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0 && + new TextEncoder().encode(value).length <= 512 && + !/[\u0000-\u001f\u007f-\u009f]/u.test(value); +} + +function nullableId(value: unknown): value is string | null { + return value === null || id(value); +} + +export function readConversationAttempt( + storage: Pick, + scope: ConversationScope, +): ConversationAttempt | null { + const raw = storage.getItem(conversationAttemptKey(scope)); + if (raw === null) return null; + const value: unknown = JSON.parse(raw); + if ( + !record(value) || !exact(value, ["version", "scope", "attempt"]) || + value.version !== 1 || + !record(value.scope) || + !exact(value.scope, ["workspaceId", "subjectId", "accountId"]) || + value.scope.workspaceId !== scope.workspaceId || + value.scope.subjectId !== scope.subjectId || + value.scope.accountId !== scope.accountId || !record(value.attempt) + ) { + throw new Error( + "Saved send input cannot be read. Do not replace it with a new send.", + ); + } + const attempt = value.attempt; + if ( + !exact(attempt, [ + "conversationId", + "operationId", + "text", + "replyTo", + "workRef", + ]) || + !id(attempt.conversationId) || !id(attempt.operationId) || + typeof attempt.text !== "string" || + !attempt.text.trim() || !nullableId(attempt.replyTo) || + !nullableId(attempt.workRef) + ) { + throw new Error( + "Saved send input is invalid. Do not replace it with a new send.", + ); + } + return Object.freeze({ + conversationId: attempt.conversationId, + operationId: attempt.operationId, + text: attempt.text, + replyTo: attempt.replyTo, + workRef: attempt.workRef, + }); +} + +function sameAttempt(a: ConversationAttempt, b: ConversationAttempt): boolean { + return a.conversationId === b.conversationId && + a.operationId === b.operationId && + a.text === b.text && a.replyTo === b.replyTo && a.workRef === b.workRef; +} + +export function saveConversationAttempt( + storage: AttemptStorage, + scope: ConversationScope, + attempt: ConversationAttempt, +): void { + const saved = readConversationAttempt(storage, scope); + if (saved !== null && !sameAttempt(saved, attempt)) { + throw new Error("Resolve the saved send before starting another send."); + } + storage.setItem( + conversationAttemptKey(scope), + JSON.stringify({ version: 1, scope, attempt }), + ); +} + +export function clearConversationAttempt( + storage: AttemptStorage, + scope: ConversationScope, + expected: ConversationAttempt, +): void { + const saved = readConversationAttempt(storage, scope); + if (saved !== null && !sameAttempt(saved, expected)) { + throw new Error("The saved send changed. It has not been cleared."); + } + storage.removeItem(conversationAttemptKey(scope)); +}