feat: prepare Subject conversation transport and exact-input retries

Preserve the light-stage implementation before integrating published Subject defaults. Retry journal units pass; generated chat types, route integration and connected validation follow in this work branch.
This commit is contained in:
2026-10-31 13:12:28 +09:00
parent 7252e61a23
commit d91181912b
9 changed files with 1120 additions and 1 deletions
+4
View File
@@ -43,6 +43,10 @@ name = "generate_worker_launch_api_types"
[[example]] [[example]]
name = "generate_companion_api_types" name = "generate_companion_api_types"
[[example]]
name = "generate_chat_api_types"
required-features = ["typescript"]
[[example]] [[example]]
name = "generate_memory_api_types" name = "generate_memory_api_types"
required-features = ["typescript"] required-features = ["typescript"]
@@ -0,0 +1,6 @@
fn main() {
print!(
"{}",
server_api::normalized_typescript(server_api::chat_api_typescript())
);
}
+42
View File
@@ -129,11 +129,53 @@ pub struct ChatMessagePage {
pub has_more: bool, 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::<Vec<_>>()
.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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use serde_json::json; 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::<String>()
};
assert_eq!(normalize(&actual), normalize(&chat_api_typescript()));
}
#[test] #[test]
fn text_only_requests_reject_attachments_and_claimed_sender() { fn text_only_requests_reject_attachments_and_claimed_sender() {
for field in ["attachments", "sender", "subject_id", "account_id"] { for field in ["attachments", "sender", "subject_id", "account_id"] {
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@8.3.2 dev", "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", "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", "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: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: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", "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",
@@ -0,0 +1,235 @@
<script lang="ts">
import { untrack } from 'svelte';
import { CHAT_LIMITS, type ChatConversation, type ChatDelivery, type ChatMessage } from '#lib/generated/chat-api.ts';
import { browserSession } from '#lib/workspace/auth/session-state.ts';
import { ConversationApiError, appendConversationMessages, getConversationPage, openSubjectConversation, sendConversationInput } from './conversation-api.ts';
import { clearConversationAttempt, readConversationAttempt, saveConversationAttempt, type ConversationAttempt, type ConversationScope } from './conversation-attempt.ts';
// 本体の現在状態・起動停止はroute側の共有SubjectBodyが所有する。会話ACKから推測しない。
let { workspaceId, subjectId, accountId, subjectName }: {
workspaceId: string;
subjectId: string;
accountId: string;
subjectName: string;
} = $props();
let conversation = $state<ChatConversation | null>(null);
let messages = $state<ChatMessage[]>([]);
let hasMore = $state(false);
let reading = $state(false);
let sending = $state(false);
let draft = $state('');
let attempt = $state<ConversationAttempt | null>(null);
let delivery = $state<ChatDelivery | null>(null);
let historyError = $state<string | null>(null);
let sendError = $state<string | null>(null);
let storageBlocked = $state(false);
let inaccessible = $state(false);
let active: { scope: ConversationScope; controller: AbortController; authGeneration: number; authStamp: string | null } | null = null;
const draftBytes = $derived(new TextEncoder().encode(draft).length);
function current(context: NonNullable<typeof active>): boolean {
const snapshot = browserSession.snapshot;
return active === context && !context.controller.signal.aborted &&
workspaceId === context.scope.workspaceId && subjectId === context.scope.subjectId && accountId === context.scope.accountId &&
browserSession.authenticationGeneration === context.authGeneration && browserSession.authenticationStamp === context.authStamp &&
snapshot.kind === 'ready' && snapshot.value.session.kind === 'authenticated' && snapshot.value.actor?.account_id === context.scope.accountId;
}
$effect(() => {
const scope: ConversationScope = { workspaceId, subjectId, accountId };
return untrack(() => {
const context = { scope, controller: new AbortController(), authGeneration: browserSession.authenticationGeneration, authStamp: browserSession.authenticationStamp };
active = context;
conversation = null;
messages = [];
hasMore = false;
reading = false;
sending = false;
draft = '';
attempt = null;
delivery = null;
historyError = null;
sendError = null;
storageBlocked = false;
inaccessible = false;
try { attempt = readConversationAttempt(sessionStorage, scope); }
catch { storageBlocked = true; sendError = 'Saved send input cannot be read. No new send will be started.'; }
void refresh(context);
// 公開履歴だけを再取得する。内部Session購読やモデルのpollingはしない。
const timer = setInterval(() => { if (!document.hidden) void refresh(context); }, 4000);
const reconnect = () => { if (!document.hidden) void refresh(context); };
window.addEventListener('online', reconnect);
document.addEventListener('visibilitychange', reconnect);
const unsubscribe = browserSession.subscribe(() => {
if (!current(context)) {
context.controller.abort();
messages = [];
conversation = null;
inaccessible = true;
}
});
return () => {
context.controller.abort();
if (active === context) active = null;
clearInterval(timer);
window.removeEventListener('online', reconnect);
document.removeEventListener('visibilitychange', reconnect);
unsubscribe();
};
});
});
async function refresh(context = active): Promise<void> {
if (!context || !current(context) || reading || inaccessible) return;
reading = true;
try {
const opened = conversation ?? await openSubjectConversation(fetch, context.scope, context.controller.signal);
if (!current(context)) return;
if (attempt && attempt.conversationId !== opened.id) {
storageBlocked = true;
sendError = 'The saved send belongs to another conversation. It has not been replaced.';
}
const after = messages.at(-1)?.sequence ?? 0;
const page = await getConversationPage(fetch, context.scope, opened.id, after, context.controller.signal);
if (!current(context)) return;
// POSTのmessageをcursorにしない。先行する未読の公開返信を飛ばさない。
conversation = page.conversation;
messages = appendConversationMessages(messages, page.messages);
hasMore = page.has_more;
historyError = null;
} catch (error) {
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 = [];
}
} finally { if (current(context)) reading = false; }
}
async function send(retry = false): Promise<void> {
const context = active;
if (!context || !current(context) || sending || inaccessible || storageBlocked || !conversation) return;
if (!retry && (attempt || !draft.trim() || draftBytes > CHAT_LIMITS.maxTextBytes)) return;
const request = retry ? attempt : Object.freeze({ conversationId: conversation.id, operationId: crypto.randomUUID(), text: draft, replyTo: null, workRef: null });
if (!request || request.conversationId !== conversation.id) return;
// 同期保存が成功するまでPOSTしない。unknownでの編集や再訪でも同じ全入力を使う。
try { saveConversationAttempt(sessionStorage, context.scope, request); }
catch { storageBlocked = true; sendError = 'Send input could not be retained safely. Nothing new was dispatched.'; return; }
attempt = request;
if (!retry) draft = '';
sending = true;
delivery = null;
sendError = null;
try {
const response = await sendConversationInput(fetch, context.scope, request, context.controller.signal);
if (!current(context)) return;
delivery = response.delivery;
if (response.delivery.state === 'accepted') {
try {
clearConversationAttempt(sessionStorage, context.scope, request);
attempt = null;
} catch {
storageBlocked = true;
sendError = 'Runtime accepted this input, but its local retry record could not be cleared. No new send will be started.';
}
}
void refresh(context);
} catch (error) {
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;
}
} finally { if (current(context)) sending = false; }
}
function recheckStorage(): void {
const context = active;
if (!context || !current(context) || sending) return;
try {
const saved = readConversationAttempt(sessionStorage, context.scope);
if (attempt) {
// clear失敗後の再確認でも新しい配送は行わない。
if (delivery?.state === 'accepted') {
clearConversationAttempt(sessionStorage, context.scope, attempt);
attempt = null;
} else {
saveConversationAttempt(sessionStorage, context.scope, attempt);
}
} else attempt = saved;
if (attempt && conversation && attempt.conversationId !== conversation.id) throw new Error();
storageBlocked = false;
sendError = null;
} catch { sendError = 'The original send input still cannot be retained or reconciled safely. No new send was started.'; }
}
function deliveryLabel(value: ChatDelivery): string {
switch (value.state) {
case 'accepted': return 'Runtime accepted this input. Processing and replies are not confirmed.';
case 'no_current_body': return 'Message saved; no current body was available. Start the Subject, then retry this same input.';
case 'rejected': return 'Message saved; Runtime did not accept this input. Check the body state before retrying.';
case 'unknown': return 'Message saved; Runtime acceptance is unknown. Retry only this same input.';
case 'unavailable': return 'Message saved; delivery is unavailable. Retry only this same input.';
}
}
</script>
<section class="subject-conversation" aria-label="Conversation with {subjectName}">
<header>
<h2>Conversation</h2>
{#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}
<ol aria-label="Saved public messages">
{#each messages as message (message.id)}
<li>
<strong>{message.sender.kind === 'user' ? 'You' : subjectName}</strong>
<p class="message-text">{message.text}</p>
</li>
{/each}
</ol>
{#if conversation && messages.length === 0}<p>No public messages yet.</p>{/if}
{#if hasMore && !inaccessible}<button type="button" onclick={() => refresh()} disabled={reading}>Load newer messages</button>{/if}
<div class="send-status" aria-live="polite">
{#if sending}<p>Sending saved input…</p>
{:else if delivery}<p>{deliveryLabel(delivery)}</p>
{:else if attempt}<p>Previous send outcome is unknown. Its original input is retained.</p>{/if}
</div>
{#if sendError}<p class="error" role="alert">{sendError}</p>{/if}
{#if storageBlocked}<button type="button" onclick={recheckStorage} disabled={sending}>Recheck retry storage</button>{/if}
{#if attempt}
<details><summary>Retained send input</summary><p class="message-text">{attempt.text}</p></details>
<p>Retry uses the same ID and full input. Runtime duplicate suppression has bounded receipt retention; it is not an indefinite delivery guarantee.</p>
{#if !inaccessible && !storageBlocked}<button type="button" onclick={() => send(true)} disabled={sending || !conversation}>Retry same input</button>{/if}
{/if}
{#if !inaccessible}
<form onsubmit={(event) => { event.preventDefault(); void send(); }}>
<label for="subject-conversation-input">Message to {subjectName}</label>
<textarea id="subject-conversation-input" bind:value={draft} rows="4" aria-describedby="conversation-constraint"></textarea>
<p id="conversation-constraint">Text only, up to {CHAT_LIMITS.maxTextBytes} UTF-8 bytes. Attachments are not supported.</p>
{#if draftBytes > CHAT_LIMITS.maxTextBytes}<p class="error">The message exceeds the text limit.</p>{/if}
{#if attempt}<p>Resolve the retained send before sending another message. Your next draft remains editable.</p>{/if}
<button type="submit" disabled={sending || !!attempt || storageBlocked || !conversation || !draft.trim() || draftBytes > CHAT_LIMITS.maxTextBytes}>Send message</button>
</form>
{/if}
</section>
<style>
.subject-conversation { display: grid; gap: var(--space-4); font-size: var(--font-size-body); line-height: var(--line-height-body); min-width: 0; }
header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--space-3); }
h2, p { margin: 0; }
h2 { font-size: var(--font-size-body); }
ol { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-5); }
li { display: grid; gap: var(--space-1); }
.message-text { white-space: pre-wrap; overflow-wrap: anywhere; }
form { display: grid; gap: var(--space-2); }
textarea { width: 100%; box-sizing: border-box; resize: vertical; font: inherit; color: var(--text); background: var(--bg); border: 1px solid var(--line-strong); padding: var(--space-2); }
button { justify-self: start; }
.error { color: var(--danger); }
</style>
@@ -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>): 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<string, string>();
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");
}
});
@@ -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<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
invalid();
}
const record = value as Record<string, unknown>;
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<string, unknown>).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<string>();
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<string, unknown>).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<unknown> {
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<ChatConversation> {
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<ChatMessagePage> {
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<ChatReceiveResponse> {
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) };
}
@@ -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<string, string>();
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)
);
});
@@ -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<Storage, "getItem" | "setItem" | "removeItem">;
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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function exact(value: Record<string, unknown>, 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<AttemptStorage, "getItem">,
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));
}