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:
@@ -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"]
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
fn main() {
|
||||
print!(
|
||||
"{}",
|
||||
server_api::normalized_typescript(server_api::chat_api_typescript())
|
||||
);
|
||||
}
|
||||
@@ -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::<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)]
|
||||
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::<String>()
|
||||
};
|
||||
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"] {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user