fix: reconcile T-697 generated contracts with latest develop

This commit is contained in:
2026-10-05 19:35:04 +09:00
30 changed files with 3464 additions and 307 deletions
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:4dc4cdeb9c6f3830b9797af0dc286a1baa6b07d9ed0bf1e576712cd155582ece)
// Input contract: 0.1.0 (sha256:f09c555159412f3e5ec7328317cc99b9d3916f6e8b9d78235f16a6b3f7e2eab0)
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -9,6 +9,10 @@ export const MEMORY_API_LIMITS = {
maxIdentifierBytes: 512,
} as const;
export const SUBJEKTIV_API_LIMITS = {
maxBehaviorBytes: 16384,
} as const;
export type DiagnosticSeverity = "info" | "warning" | "error";
export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, };
@@ -35,9 +39,11 @@ export type MemoryStagingListResponse = { limit: number, returned_count: number,
export type SubjektivSubjectState = "active" | "retired";
export type SubjektivSubjectCreateRequest = { role: string, };
export type SubjektivSubjectCreateRequest = { role: string, behavior_md?: string, };
export type SubjektivSubjectResponse = { id: string, role: string, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, };
export type SubjektivSubjectBehaviorUpdateRequest = { expected_behavior_revision: number, behavior_md: string, };
export type SubjektivSubjectResponse = { id: string, role: string, behavior_md: string, behavior_revision: number, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, };
export type SubjektivSubjectListResponse = { limit: number, items: Array<SubjektivSubjectResponse>, next_cursor?: string | null, has_more: boolean, };
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:4dc4cdeb9c6f3830b9797af0dc286a1baa6b07d9ed0bf1e576712cd155582ece)
// Input contract: 0.1.0 (sha256:f09c555159412f3e5ec7328317cc99b9d3916f6e8b9d78235f16a6b3f7e2eab0)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:4dc4cdeb9c6f3830b9797af0dc286a1baa6b07d9ed0bf1e576712cd155582ece)
// Input contract: 0.1.0 (sha256:f09c555159412f3e5ec7328317cc99b9d3916f6e8b9d78235f16a6b3f7e2eab0)
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:4dc4cdeb9c6f3830b9797af0dc286a1baa6b07d9ed0bf1e576712cd155582ece)
// Input contract: 0.1.0 (sha256:f09c555159412f3e5ec7328317cc99b9d3916f6e8b9d78235f16a6b3f7e2eab0)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:4dc4cdeb9c6f3830b9797af0dc286a1baa6b07d9ed0bf1e576712cd155582ece)
// Input contract: 0.1.0 (sha256:f09c555159412f3e5ec7328317cc99b9d3916f6e8b9d78235f16a6b3f7e2eab0)
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/workdir-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:4dc4cdeb9c6f3830b9797af0dc286a1baa6b07d9ed0bf1e576712cd155582ece)
// Input contract: 0.1.0 (sha256:f09c555159412f3e5ec7328317cc99b9d3916f6e8b9d78235f16a6b3f7e2eab0)
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
+107 -5
View File
@@ -1,4 +1,7 @@
import { MEMORY_API_LIMITS } from "#lib/generated/memory-api.ts";
import {
MEMORY_API_LIMITS,
SUBJEKTIV_API_LIMITS,
} from "#lib/generated/memory-api.ts";
import { readBoundedJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
import type {
Diagnostic,
@@ -26,6 +29,7 @@ import type {
SubjektivResidentSurfaceAvailability,
SubjektivResidentSurfaceResponse,
SubjektivResidentSurfaceSnapshot,
SubjektivSubjectBehaviorUpdateRequest,
SubjektivSubjectCreateRequest,
SubjektivSubjectListResponse,
SubjektivSubjectResponse,
@@ -84,6 +88,7 @@ export const MEMORY_API_LOAD_POLICY = {
} as const;
const MAX_SUBJECT_ROLE_BYTES = 256;
export const MAX_SUBJECT_BEHAVIOR_BYTES = SUBJEKTIV_API_LIMITS.maxBehaviorBytes;
const MAX_SUBJECT_CREATE_ERROR_BYTES = 1024 * 1024;
export type SubjektivSubjectCreateErrorKind =
@@ -126,7 +131,31 @@ export function validateSubjektivSubjectCreateRequest(
"Subject role must be at most 256 bytes and contain no control characters.",
);
}
return { role: request.role };
const behaviorMd = request.behavior_md ?? "";
validateSubjectBehavior(behaviorMd);
return { role: request.role, behavior_md: behaviorMd };
}
function validateSubjectBehavior(behaviorMd: string): void {
const bytes = new TextEncoder().encode(behaviorMd).byteLength;
if (bytes > MAX_SUBJECT_BEHAVIOR_BYTES) {
throw new SubjektivSubjectCreateError(
"validation",
`Subject behavior must be at most ${MAX_SUBJECT_BEHAVIOR_BYTES} bytes.`,
);
}
if (behaviorMd.length > 0 && behaviorMd.trim().length === 0) {
throw new SubjektivSubjectCreateError(
"validation",
"Subject behavior must be empty or contain non-whitespace text.",
);
}
if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/u.test(behaviorMd)) {
throw new SubjektivSubjectCreateError(
"validation",
"Subject behavior contains an unsupported control character.",
);
}
}
export async function createSubjektivSubject(
@@ -170,8 +199,11 @@ export async function createSubjektivSubject(
const subject = parseSubjektivSubjectResponse(
await readBoundedJson(response, MEMORY_API_LIMITS.maxResponseBytes),
);
if (subject.role !== validated.role) {
throw new Error("created Subject role does not match the request");
if (
subject.role !== validated.role ||
subject.behavior_md !== validated.behavior_md
) {
throw new Error("created Subject does not match the request");
}
return subject;
} catch {
@@ -182,6 +214,68 @@ export async function createSubjektivSubject(
}
}
export async function updateSubjektivSubjectBehavior(
fetchFn: typeof fetch,
workspaceId: string,
subjectId: string,
request: SubjektivSubjectBehaviorUpdateRequest,
): Promise<SubjektivSubjectResponse> {
validateSubjectBehavior(request.behavior_md);
if (
!Number.isSafeInteger(request.expected_behavior_revision) ||
request.expected_behavior_revision < 0
) {
throw new SubjektivSubjectCreateError(
"validation",
"Subject behavior revision is invalid.",
);
}
let response: Response;
try {
response = await fetchFn(
workspaceApiPath(
workspaceId,
`/subjektiv/subjects/${encodeURIComponent(subjectId)}/behavior`,
),
{
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
},
);
} catch {
throw new SubjektivSubjectCreateError(
"unknown_outcome",
"The save outcome is unknown. Reload the Subject before retrying.",
);
}
if (!response.ok) {
const message = await subjektivSubjectCreateErrorMessage(response);
throw new SubjektivSubjectCreateError(
response.status >= 500 ? "unknown_outcome" : "rejected",
response.status === 409
? "The behavior changed elsewhere. Reload the Subject and review the latest text."
: message,
response.status,
);
}
try {
const subject = parseSubjektivSubjectResponse(
await readBoundedJson(response, MEMORY_API_LIMITS.maxResponseBytes),
subjectId,
);
if (subject.behavior_md !== request.behavior_md) {
throw new Error("saved Subject behavior does not match the request");
}
return subject;
} catch {
throw new SubjektivSubjectCreateError(
"unknown_outcome",
"The behavior may have been saved, but the response could not be confirmed. Reload the Subject.",
);
}
}
async function subjektivSubjectCreateErrorMessage(
response: Response,
): Promise<string> {
@@ -202,7 +296,7 @@ async function subjektivSubjectCreateErrorMessage(
} catch {
// A rejected request still has a known outcome even when its body is invalid.
}
return `Subject creation failed with HTTP ${response.status}.`;
return `Subject request failed with HTTP ${response.status}.`;
}
export function parseMemoryDocumentResponse(
@@ -454,6 +548,8 @@ export function parseSubjektivSubjectResponse(
[
"id",
"role",
"behavior_md",
"behavior_revision",
"state",
"store_revision",
"created_at",
@@ -472,6 +568,12 @@ export function parseSubjektivSubjectResponse(
const result: SubjektivSubjectResponse = {
id,
role: requiredString(record, "role"),
behavior_md: requiredString(
record,
"behavior_md",
MAX_SUBJECT_BEHAVIOR_BYTES,
),
behavior_revision: requiredNonNegativeInteger(record, "behavior_revision"),
state,
store_revision: requiredNonNegativeInteger(record, "store_revision"),
created_at: requiredString(record, "created_at"),
@@ -14,13 +14,16 @@
const subjects = $derived(data.subjects.data?.items ?? []);
let createExpanded = $state(false);
let role = $state('');
let behaviorMd = $state('');
let submitting = $state(false);
let createError = $state<string | null>(null);
let roleInvalid = $state(false);
let behaviorInvalid = $state(false);
let createOutcomeUnknown = $state(false);
let createdSubject = $state<SubjektivSubjectResponse | null>(null);
let createButton = $state<HTMLButtonElement>();
let roleInput = $state<HTMLInputElement>();
let behaviorInput = $state<HTMLTextAreaElement>();
function subjectHref(subjectId: string): string {
return workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(subjectId)}`);
@@ -39,6 +42,7 @@
createExpanded = true;
createError = null;
roleInvalid = false;
behaviorInvalid = false;
createOutcomeUnknown = false;
createdSubject = null;
await tick();
@@ -49,8 +53,10 @@
if (submitting) return;
createExpanded = false;
role = '';
behaviorMd = '';
createError = null;
roleInvalid = false;
behaviorInvalid = false;
createOutcomeUnknown = false;
createdSubject = null;
await tick();
@@ -62,9 +68,13 @@
submitting = true;
createError = null;
roleInvalid = false;
behaviorInvalid = false;
createOutcomeUnknown = false;
try {
const subject = await createSubjektivSubject(fetch, data.workspaceId, { role });
const subject = await createSubjektivSubject(fetch, data.workspaceId, {
role,
behavior_md: behaviorMd,
});
createdSubject = subject;
try {
await goto(subjectHref(subject.id));
@@ -73,14 +83,16 @@
}
} catch (error) {
if (error instanceof SubjektivSubjectCreateError) {
roleInvalid = error.kind === 'validation';
roleInvalid = error.kind === 'validation' && error.message.startsWith('Subject role');
behaviorInvalid = error.kind === 'validation' && error.message.startsWith('Subject behavior');
createOutcomeUnknown = error.kind === 'unknown_outcome';
createError = error.status === 401 || error.status === 403
? 'You do not have permission to create Subjects in this Workspace.'
: error.message;
if (roleInvalid) {
if (roleInvalid || behaviorInvalid) {
await tick();
roleInput?.focus();
if (behaviorInvalid) behaviorInput?.focus();
else roleInput?.focus();
}
} else {
createOutcomeUnknown = true;
@@ -149,9 +161,22 @@
disabled={submitting || createdSubject !== null}
onkeydown={createFormKeydown}
aria-invalid={roleInvalid ? 'true' : undefined}
aria-describedby={createError ? 'subject-role-help subject-create-error' : 'subject-role-help'}
aria-describedby={createError && roleInvalid ? 'subject-role-help subject-create-error' : 'subject-role-help'}
/>
</label>
<label class="subject-role-field">
<span>Behavior <small>Optional, user-managed</small></span>
<textarea
bind:this={behaviorInput}
bind:value={behaviorMd}
rows="7"
disabled={submitting || createdSubject !== null}
onkeydown={createFormKeydown}
aria-invalid={behaviorInvalid ? 'true' : undefined}
aria-describedby={createError && behaviorInvalid ? 'subject-behavior-help subject-create-error' : 'subject-behavior-help'}
></textarea>
<small id="subject-behavior-help">Persistent guidance injected verbatim for this Subject. It remains separate from generated Memory and can be edited later.</small>
</label>
{#if createError}
<div
id="subject-create-error"
@@ -318,20 +343,25 @@
}
.subject-create-actions {
grid-column: 3;
grid-row: 1;
grid-column: 2;
}
.subject-create-panel {
display: grid;
grid-template-columns: minmax(14rem, 0.8fr) minmax(18rem, 1fr) auto;
align-items: end;
grid-template-columns: minmax(14rem, 0.7fr) minmax(18rem, 1.3fr);
align-items: start;
gap: var(--space-4);
padding: var(--space-4);
background: var(--bg-raised);
border-radius: var(--radius-soft);
}
.subject-create-copy {
grid-column: 1;
grid-row: 1 / span 4;
align-self: start;
}
.subject-create-copy h2,
.subject-create-copy p,
.subject-create-error p {
@@ -353,7 +383,8 @@
font-weight: 700;
}
.subject-role-field input {
.subject-role-field input,
.subject-role-field textarea {
width: 100%;
min-width: 0;
border: 1px solid var(--line);
@@ -363,7 +394,15 @@
color: var(--text-strong);
}
.subject-role-field input[aria-invalid='true'] {
.subject-role-field textarea {
min-height: 8rem;
resize: vertical;
font: inherit;
line-height: 1.5;
}
.subject-role-field input[aria-invalid='true'],
.subject-role-field textarea[aria-invalid='true'] {
border-color: var(--danger);
}
@@ -496,6 +535,7 @@
align-items: stretch;
}
.subject-create-copy,
.subject-create-error,
.subject-create-actions {
grid-column: 1;
@@ -1,14 +1,91 @@
<script lang="ts">
import { untrack } from 'svelte';
import DocumentMarkdown from '#lib/workspace/markdown/DocumentMarkdown.svelte';
import { formatDate, workspaceRoute } from '#lib/workspace/api/http.ts';
import type {
SubjektivMemoryState,
SubjektivResidentSurfaceAvailability,
} from '#lib/generated/memory-api.ts';
import {
SubjektivSubjectCreateError,
updateSubjektivSubjectBehavior,
} from '#lib/workspace/memory/api.ts';
import type { SubjektivMemoryState, SubjektivResidentSurfaceAvailability } from '#lib/generated/memory-api.ts';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let subject = $state(untrack(() => data.subject.data));
let editingBehavior = $state(false);
let behaviorDraft = $state(untrack(() => subject?.behavior_md ?? ''));
let behaviorSaving = $state(false);
let behaviorError = $state<string | null>(null);
let behaviorSaveConfirmed = $state(false);
let routeGeneration = 0;
let subjectWorkspaceId = untrack(() => data.workspaceId);
// SvelteKit reuses this page across Subject and Workspace routes. A new
// loader snapshot owns a new editor and fences all previous mutations.
$effect(() => {
const workspaceId = data.workspaceId;
const subjectId = data.subjectId;
const loadedSubject = data.subject.data;
untrack(() => {
routeGeneration += 1;
subjectWorkspaceId = workspaceId;
subject = loadedSubject?.id === subjectId ? loadedSubject : null;
behaviorDraft = subject?.behavior_md ?? '';
editingBehavior = false;
behaviorSaving = false;
behaviorError = null;
behaviorSaveConfirmed = false;
});
});
async function saveBehavior(): Promise<void> {
if (!subject || behaviorSaving || subject.id !== data.subjectId || subjectWorkspaceId !== data.workspaceId) return;
const generation = routeGeneration;
const workspaceId = subjectWorkspaceId;
const subjectId = subject.id;
const request = {
expected_behavior_revision: subject.behavior_revision,
behavior_md: behaviorDraft,
};
const isCurrent = () => generation === routeGeneration &&
data.workspaceId === workspaceId && data.subjectId === subjectId;
behaviorSaving = true;
behaviorError = null;
behaviorSaveConfirmed = false;
try {
const updated = await updateSubjektivSubjectBehavior(fetch, workspaceId, subjectId, request);
if (!isCurrent()) return;
subject = updated;
behaviorDraft = updated.behavior_md;
editingBehavior = false;
behaviorSaveConfirmed = true;
} catch (error) {
if (!isCurrent()) return;
behaviorError = error instanceof SubjektivSubjectCreateError
? (error.status === 401 || error.status === 403
? 'You do not have permission to edit this Subject.'
: error.message)
: 'The save outcome is unknown. Reload the Subject before retrying.';
} finally {
if (isCurrent()) behaviorSaving = false;
}
}
function beginBehaviorEdit(): void {
if (!subject) return;
behaviorDraft = subject.behavior_md;
behaviorError = null;
behaviorSaveConfirmed = false;
editingBehavior = true;
}
function cancelBehaviorEdit(): void {
behaviorDraft = subject?.behavior_md ?? '';
behaviorError = null;
editingBehavior = false;
}
const surface = $derived(data.surface.data);
const snapshot = $derived(surface?.snapshot ?? null);
const memories = $derived(data.memories.data?.items ?? []);
@@ -51,7 +128,7 @@
if (availability !== 'ready') return 'No current generated context is available to use.';
return snapshot?.body_md.trim()
? 'Current generated context is available.'
: 'A current empty surface exists; there is no resident context to show.';
: 'A current empty surface exists; there is no generated Memory context to show.';
}
function subjectStateLabel(state: 'active' | 'retired'): string {
@@ -60,7 +137,7 @@
</script>
<svelte:head>
<title>{data.subject.data?.role ?? data.subjectId} · Memory · Yoi Workspace</title>
<title>{subject?.role ?? data.subjectId} · Memory · Yoi Workspace</title>
<meta name="description" content="Subject resident surface and current committed Memories" />
</svelte:head>
@@ -70,23 +147,23 @@
<header class="memory-page-header">
<div>
<p class="memory-eyebrow">Memory subject</p>
<h1 id="subject-heading">{data.subject.data?.role ?? data.subjectId}</h1>
<h1 id="subject-heading">{subject?.role ?? data.subjectId}</h1>
<code class="subject-id" title={data.subjectId}>{data.subjectId}</code>
</div>
<span class="read-only-label">Read-only</span>
<span class="read-only-label">Behavior editable</span>
</header>
{#if data.subject.data}
{#if subject}
<section class="subject-overview" aria-label="Subject status">
<div>
<span>Subject status</span>
<strong class="subject-state is-{data.subject.data.state}">{subjectStateLabel(data.subject.data.state)}</strong>
<p>{data.subject.data.state === 'active' ? 'Available for new Worker connections.' : 'No longer available for new Worker connections.'}</p>
<strong class="subject-state is-{subject.state}">{subjectStateLabel(subject.state)}</strong>
<p>{subject.state === 'active' ? 'Available for new Worker connections.' : 'No longer available for new Worker connections.'}</p>
</div>
<div>
<span>Worker connection</span>
<strong>{data.subject.data.current_worker ? 'Connected' : 'Not connected'}</strong>
<p>{data.subject.data.current_worker?.display_name ?? 'No Worker currently owns this Subject connection.'}</p>
<strong>{subject.current_worker ? 'Connected' : 'Not connected'}</strong>
<p>{subject.current_worker?.display_name ?? 'No Worker currently owns this Subject connection.'}</p>
</div>
<div>
<span>Committed Memories</span>
@@ -99,7 +176,7 @@
{/if}
</div>
<div>
<span>Resident context</span>
<span>Generated Memory context</span>
{#if surface}
<strong class="availability is-{surface.availability}">{surfaceLabel(surface.availability)}</strong>
<p>{surfaceSummary(surface.availability)}</p>
@@ -113,12 +190,12 @@
<details class="subject-technical-details">
<summary>Technical details</summary>
<dl>
<div><dt>Subject ID</dt><dd><code>{data.subject.data.id}</code></dd></div>
<div><dt>Subject ID</dt><dd><code>{subject.id}</code></dd></div>
<div>
<dt>Subject store revision</dt>
<dd>{data.subject.data.store_revision}<small>Internal change number for committed Memories; not a Memory count or content-quality score.</small></dd>
<dd>{subject.store_revision}<small>Internal change number for committed Memories; not a Memory count or content-quality score.</small></dd>
</div>
<div><dt>Last changed</dt><dd><time datetime={data.subject.data.updated_at}>{formatDate(data.subject.data.updated_at)}</time></dd></div>
<div><dt>Last changed</dt><dd><time datetime={subject.updated_at}>{formatDate(subject.updated_at)}</time></dd></div>
</dl>
</details>
{:else if data.subject.error}
@@ -130,6 +207,40 @@
<div class="memory-state" role="status"><p>Subject details are unavailable.</p></div>
{/if}
{#if subject}
<section class="behavior-section" aria-labelledby="subject-behavior-heading">
<header class="section-heading">
<div>
<p class="memory-eyebrow">User-managed context</p>
<h2 id="subject-behavior-heading">Behavior</h2>
</div>
{#if !editingBehavior}
<button type="button" class="behavior-edit" onclick={beginBehaviorEdit}>Edit</button>
{/if}
</header>
<p class="behavior-help">The Host injects this text verbatim for connected Workers, separately from generated Memory. Saving confirms storage only. Connected Workers check for the latest revision before later model requests; this page does not report that application.</p>
{#if editingBehavior}
<form class="behavior-form" aria-busy={behaviorSaving} onsubmit={(event) => { event.preventDefault(); void saveBehavior(); }}>
<label>
<span class="sr-only">Subject behavior</span>
<textarea bind:value={behaviorDraft} rows="9" disabled={behaviorSaving} aria-invalid={behaviorError ? 'true' : undefined} aria-describedby={behaviorError ? 'subject-behavior-edit-error' : undefined}></textarea>
</label>
{#if behaviorError}<p id="subject-behavior-edit-error" class="behavior-error" role="alert">{behaviorError}</p>{/if}
<div class="behavior-actions">
<button type="submit" disabled={behaviorSaving}>{behaviorSaving ? 'Saving…' : 'Save behavior'}</button>
<button type="button" disabled={behaviorSaving} onclick={cancelBehaviorEdit}>Cancel</button>
<button type="button" disabled={behaviorSaving || behaviorDraft.length === 0} onclick={() => { behaviorDraft = ''; }}>Clear</button>
</div>
</form>
{:else if subject.behavior_md.length > 0}
<article class="behavior-document" aria-label="User-managed Subject behavior"><pre>{subject.behavior_md}</pre></article>
{:else}
<div class="memory-state" role="status"><strong>No behavior is set.</strong><p>Connected Workers receive an explicit empty behavior document.</p></div>
{/if}
{#if behaviorSaveConfirmed}<p class="behavior-saved" role="status">Behavior storage confirmed at revision {subject.behavior_revision}. This page does not confirm application by a connected Worker.</p>{/if}
</section>
{/if}
<section class="surface-section" aria-labelledby="resident-surface-heading">
<header class="section-heading">
<div>
@@ -434,6 +545,7 @@
color: var(--danger);
}
.behavior-section,
.surface-section,
.memories-section {
display: grid;
@@ -442,6 +554,78 @@
padding-top: var(--space-3);
}
.behavior-help,
.behavior-saved,
.behavior-error {
margin: 0;
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.behavior-error { color: var(--danger); }
.behavior-saved { color: var(--success); }
.behavior-form,
.behavior-form label {
display: grid;
gap: var(--space-3);
}
.behavior-form textarea {
width: 100%;
min-height: 10rem;
padding: var(--space-3);
color: var(--text);
background: var(--surface-raised);
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
font: inherit;
line-height: 1.5;
resize: vertical;
}
.behavior-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.behavior-edit,
.behavior-actions button {
padding: var(--space-2) var(--space-3);
color: var(--text);
background: var(--surface-raised);
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
cursor: pointer;
}
.behavior-actions button:disabled { cursor: not-allowed; opacity: 0.55; }
.behavior-document pre {
margin: 0;
padding: var(--space-4);
overflow-wrap: anywhere;
white-space: pre-wrap;
background: var(--surface-raised);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
font: inherit;
line-height: 1.6;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.surface-meta,
.pill-row {
display: flex;
@@ -34,6 +34,8 @@ function subject() {
return {
id: "subject-1",
role: "Release coordinator",
behavior_md: "Prefer explicit evidence.",
behavior_revision: 2,
state: "active" as const,
store_revision: 9,
created_at: "2026-01-01T00:00:00Z",
@@ -99,6 +101,12 @@ test("creates one Subject with the exact role and navigates to its generated det
}) as HTMLInputElement;
await waitFor(() => expect(document.activeElement).toBe(roleInput));
await fireEvent.input(roleInput, { target: { value: " Review lead " } });
const behaviorInput = screen.getByRole("textbox", {
name: /Behavior/,
}) as HTMLTextAreaElement;
await fireEvent.input(behaviorInput, {
target: { value: "Challenge unsupported assumptions." },
});
const submit = screen.getByRole("button", { name: "Create Subject" });
await fireEvent.click(submit);
@@ -113,6 +121,8 @@ test("creates one Subject with the exact role and navigates to its generated det
resolveRequest(Response.json({
id: "generated-subject-1",
role: " Review lead ",
behavior_md: "Challenge unsupported assumptions.",
behavior_revision: 0,
state: "active",
store_revision: 0,
created_at: "2026-01-03T00:00:00Z",
@@ -130,7 +140,9 @@ test("creates one Subject with the exact role and navigates to its generated det
];
expect(path).toBe("/api/w/workspace-1/subjektiv/subjects");
expect(init.method).toBe("POST");
expect(init.body).toBe('{"role":" Review lead "}');
expect(init.body).toBe(
'{"role":" Review lead ","behavior_md":"Challenge unsupported assumptions."}',
);
});
test("keeps creation available for an empty list and restores focus on cancel", async () => {
@@ -149,7 +161,7 @@ test("keeps creation available for an empty list and restores focus on cancel",
expect(screen.queryByRole("textbox", { name: "Role" })).toBeNull();
});
test("preserves invalid and rejected role drafts with accessible errors", async () => {
test("preserves invalid and rejected Subject drafts with accessible field errors", async () => {
const fetchMock = vi.fn(() =>
Promise.resolve(Response.json(
{
@@ -175,6 +187,20 @@ test("preserves invalid and rejected role drafts with accessible errors", async
expect(fetchMock).not.toHaveBeenCalled();
await fireEvent.input(input, { target: { value: "Release reviewer" } });
const behaviorInput = screen.getByRole("textbox", {
name: /Behavior/,
}) as HTMLTextAreaElement;
await fireEvent.input(behaviorInput, { target: { value: " " } });
await fireEvent.click(screen.getByRole("button", { name: "Create Subject" }));
expect(screen.getByRole("alert").textContent).toContain(
"empty or contain non-whitespace",
);
expect(behaviorInput.getAttribute("aria-invalid")).toBe("true");
expect(fetchMock).not.toHaveBeenCalled();
await fireEvent.input(behaviorInput, {
target: { value: "Challenge unsupported assumptions." },
});
await fireEvent.click(screen.getByRole("button", { name: "Create Subject" }));
await waitFor(() => {
expect(screen.getByRole("alert").textContent).toContain(
@@ -273,6 +299,11 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
expect(screen.getByRole("heading", { name: "Resident context", level: 1 }))
.not.toBeNull();
});
expect(
screen.getByRole("article", { name: "User-managed Subject behavior" })
.textContent,
)
.toContain("Prefer explicit evidence.");
expect(screen.getByRole("article", { name: "Resident context" })).not
.toBeNull();
expect(screen.getByText("Connected", { exact: true })).not.toBeNull();
@@ -292,6 +323,272 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
.toContain("cursor=memory-next");
});
test("edits and clears user-managed behavior with CAS while distinguishing save from Worker application", async () => {
const fetchMock = vi.fn(async (_path: string, init?: RequestInit) => {
const request = JSON.parse(String(init?.body));
return Response.json({
...subject(),
behavior_md: request.behavior_md,
behavior_revision: request.expected_behavior_revision + 1,
});
});
vi.stubGlobal("fetch", fetchMock);
render(SubjectPage, {
data: {
workspaceId: "workspace-1",
subjectId: "subject-1",
subject: result(subject()),
surface: result({
subject_id: "subject-1",
availability: "ungenerated" as const,
}),
memories: result({ items: [], has_more: false }),
},
} as never);
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
const textarea = screen.getByRole("textbox", { name: "Subject behavior" });
await fireEvent.input(textarea, {
target: { value: "Ask before irreversible actions." },
});
await fireEvent.click(screen.getByRole("button", { name: "Save behavior" }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
const [path, init] = fetchMock.mock.calls[0] as unknown as [
string,
RequestInit,
];
expect(path).toBe("/api/w/workspace-1/subjektiv/subjects/subject-1/behavior");
expect(init.method).toBe("PATCH");
expect(init.body).toBe(
'{"expected_behavior_revision":2,"behavior_md":"Ask before irreversible actions."}',
);
await waitFor(() => {
expect(
screen.getByText(/Behavior storage confirmed at revision 3/).textContent,
)
.toContain("does not confirm application");
});
expect(
screen.getByRole("article", { name: "User-managed Subject behavior" })
.textContent,
)
.toContain("Ask before irreversible actions.");
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
await fireEvent.click(screen.getByRole("button", { name: "Clear" }));
await fireEvent.click(screen.getByRole("button", { name: "Save behavior" }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
const secondBody =
(fetchMock.mock.calls[1] as unknown as [string, RequestInit])[1].body;
expect(secondBody).toBe('{"expected_behavior_revision":3,"behavior_md":""}');
await waitFor(() =>
expect(screen.getByText("No behavior is set.")).not.toBeNull()
);
});
function subjectPageData(record = subject(), workspaceId = "workspace-1") {
return {
workspaceId,
subjectId: record.id,
subject: result(record),
surface: result({
subject_id: record.id,
availability: "ungenerated" as const,
}),
memories: result({ items: [], has_more: false }),
};
}
test.each([
["subject-2", "workspace-1"],
["subject-1", "workspace-2"],
])(
"resets reused Subject editor for %s in %s and saves only its own behavior",
async (id, workspaceId) => {
const nextSubject = { ...subject(), id, behavior_md: "Only B behavior." };
const fetchMock = vi.fn(async (_path: string, init?: RequestInit) => {
const request = JSON.parse(String(init?.body));
return Response.json({
...nextSubject,
behavior_md: request.behavior_md,
behavior_revision: request.expected_behavior_revision + 1,
});
});
vi.stubGlobal("fetch", fetchMock);
const view = render(SubjectPage, { data: subjectPageData() } as never);
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
await fireEvent.input(
screen.getByRole("textbox", { name: "Subject behavior" }),
{
target: { value: "Unsaved A draft" },
},
);
await view.rerender(
{ data: subjectPageData(nextSubject, workspaceId) } as never,
);
expect(screen.queryByRole("textbox", { name: "Subject behavior" }))
.toBeNull();
expect(
screen.getByRole("article", { name: "User-managed Subject behavior" })
.textContent,
)
.toContain("Only B behavior.");
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
expect(
(screen.getByRole("textbox", {
name: "Subject behavior",
}) as HTMLTextAreaElement).value,
)
.toBe("Only B behavior.");
await fireEvent.click(
screen.getByRole("button", { name: "Save behavior" }),
);
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
const [path, init] = fetchMock.mock.calls[0] as unknown as [
string,
RequestInit,
];
expect(path).toBe(
`/api/w/${workspaceId}/subjektiv/subjects/${id}/behavior`,
);
expect(JSON.parse(String(init.body))).toEqual({
expected_behavior_revision: 2,
behavior_md: "Only B behavior.",
});
await waitFor(() =>
expect(screen.getByText(/storage confirmed at revision 3/)).not.toBeNull()
);
},
);
test.each(["success", "failure"])(
"fences late %s from a previous Subject without clearing the new save state",
async (outcome) => {
const pending: Array<(response: Response) => void> = [];
const fetchMock = vi.fn(() =>
new Promise<Response>((resolve) => pending.push(resolve))
);
vi.stubGlobal("fetch", fetchMock);
const view = render(SubjectPage, { data: subjectPageData() } as never);
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
await fireEvent.click(
screen.getByRole("button", { name: "Save behavior" }),
);
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
const nextSubject = {
...subject(),
id: "subject-2",
behavior_md: "Only B behavior.",
};
await view.rerender({ data: subjectPageData(nextSubject) } as never);
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
await fireEvent.input(
screen.getByRole("textbox", { name: "Subject behavior" }),
{
target: { value: "Updated B behavior." },
},
);
await fireEvent.click(
screen.getByRole("button", { name: "Save behavior" }),
);
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
pending[0](
outcome === "success"
? Response.json({
...subject(),
behavior_md: "Late A response",
behavior_revision: 3,
})
: Response.json({
error: "Forbidden",
message: "A denied",
diagnostics: [],
}, { status: 403 }),
);
// Drain the previous response through its success/catch/finally branches.
await new Promise((resolve) => setTimeout(resolve, 0));
expect(
screen.getByRole("button", { name: "Saving…" }).hasAttribute("disabled"),
).toBe(true);
expect(
(screen.getByRole("textbox", {
name: "Subject behavior",
}) as HTMLTextAreaElement).value,
)
.toBe("Updated B behavior.");
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.queryByText(/storage confirmed/)).toBeNull();
pending[1](
Response.json({
...nextSubject,
behavior_md: "Updated B behavior.",
behavior_revision: 3,
}),
);
await waitFor(() =>
expect(screen.getByText(/storage confirmed at revision 3/)).not.toBeNull()
);
expect(
screen.getByRole("article", { name: "User-managed Subject behavior" })
.textContent,
)
.toContain("Updated B behavior.");
},
);
test("a refreshed loader snapshot fences old saves even after returning to the same Subject", async () => {
let resolveRequest!: (response: Response) => void;
vi.stubGlobal(
"fetch",
vi.fn(() =>
new Promise<Response>((resolve) => {
resolveRequest = resolve;
})
),
);
const view = render(SubjectPage, { data: subjectPageData() } as never);
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
await fireEvent.click(screen.getByRole("button", { name: "Save behavior" }));
await view.rerender(
{ data: subjectPageData({ ...subject(), id: "subject-2" }) } as never,
);
const fresh = {
...subject(),
behavior_md: "Fresh A loader",
behavior_revision: 4,
};
await view.rerender({ data: subjectPageData(fresh) } as never);
resolveRequest(
Response.json({
...subject(),
behavior_md: "Stale A response",
behavior_revision: 3,
}),
);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(
screen.getByRole("article", { name: "User-managed Subject behavior" })
.textContent,
)
.toContain("Fresh A loader");
expect(screen.queryByText(/storage confirmed/)).toBeNull();
await view.rerender(
{
data: {
...subjectPageData(),
subject: result(null, "Subject unavailable"),
},
} as never,
);
expect(screen.queryByRole("button", { name: "Edit" })).toBeNull();
expect(
screen.queryByRole("article", { name: "User-managed Subject behavior" }),
).toBeNull();
});
test("candidate-only Subjects show no committed Memories, not an inferred candidate count", () => {
// The store regression candidate_decisions_reject_conflicts_and_roll_back_partial_writes
// stages a candidate without committing a Memory. Its public read projection is
@@ -320,6 +617,65 @@ test("candidate-only Subjects show no committed Memories, not an inferred candid
).toBeNull();
});
test.each(["ready", "ungenerated", "stale", "failed"] as const)(
"user behavior and diagnostic disclosures stay independent of %s generated Memory",
async (availability) => {
const record = { ...subject(), store_revision: 0 };
const view = render(SubjectPage, {
data: {
...subjectPageData(record),
surface: result({
subject_id: record.id,
availability,
...(availability === "ready"
? {
snapshot: {
snapshot_id: "empty-snapshot",
body_md: "",
memory_refs: [],
built_from_store_revision: 0,
created_at: "2026-01-02T03:04:05Z",
},
}
: {}),
}),
},
} as never);
expect(
screen.getByRole("article", { name: "User-managed Subject behavior" })
.textContent,
)
.toContain(record.behavior_md);
expect(screen.getByText("Generated Memory context", { exact: true })).not
.toBeNull();
expect(screen.getByText("No committed Memories yet.")).not.toBeNull();
expect(screen.getByText("Not connected", { exact: true })).not.toBeNull();
const technical = view.container.querySelector(
"details.subject-technical-details",
) as HTMLDetailsElement;
expect(technical.open).toBe(false);
expect(technical.textContent).toContain("Subject store revision");
if (availability === "ready") {
expect(screen.getByText("Resident context is current but empty.")).not
.toBeNull();
expect(screen.getByText(/no generated Memory context to show/)).not
.toBeNull();
expect(
view.container.querySelector("details.surface-details")?.textContent,
)
.toContain("Generated from Subject revision");
}
await fireEvent.click(screen.getByRole("button", { name: "Edit" }));
await fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(
screen.getByRole("article", { name: "User-managed Subject behavior" })
.textContent,
)
.toContain(record.behavior_md);
expect(technical.open).toBe(false);
},
);
test("distinguishes ready-empty, stale, failed, unavailable, and request error surfaces", async () => {
const base = {
workspaceId: "workspace-1",
+75 -2
View File
@@ -12,6 +12,7 @@ import {
parseSubjektivSubjectListResponse,
parseSubjektivSubjectResponse,
SubjektivSubjectCreateError,
updateSubjektivSubjectBehavior,
validateSubjektivSubjectCreateRequest,
} from "../src/lib/workspace/memory/api.ts";
@@ -73,6 +74,8 @@ function subject(id = "subject-1") {
return {
id,
role: "Release coordinator",
behavior_md: "Prefer explicit evidence.",
behavior_revision: 2,
state: "active",
store_revision: 12,
created_at: "2026-09-01T00:00:00Z",
@@ -271,7 +274,7 @@ Deno.test("Subject list parser preserves the exact bounded response", () => {
Deno.test("Subject create request mirrors Backend role validation without rewriting input", () => {
assertEquals(
validateSubjektivSubjectCreateRequest({ role: " Release coordinator " }),
{ role: " Release coordinator " },
{ role: " Release coordinator ", behavior_md: "" },
);
assertThrows(
() => validateSubjektivSubjectCreateRequest({ role: " " }),
@@ -285,6 +288,70 @@ Deno.test("Subject create request mirrors Backend role validation without rewrit
() => validateSubjektivSubjectCreateRequest({ role: "界".repeat(86) }),
"at most 256 bytes",
);
assertThrows(
() =>
validateSubjektivSubjectCreateRequest({
role: "Reviewer",
behavior_md: " ".repeat(3),
}),
"empty or contain non-whitespace",
);
assertThrows(
() =>
validateSubjektivSubjectCreateRequest({
role: "Reviewer",
behavior_md: "x".repeat(16 * 1024 + 1),
}),
"at most 16384 bytes",
);
});
Deno.test("Subject behavior update uses CAS and preserves exact text", async () => {
const requests: Array<{ path: string; init?: RequestInit }> = [];
const fetchFn = (async (path: string | URL | Request, init?: RequestInit) => {
requests.push({ path: String(path), init });
return Response.json({
...subject("subject/one"),
behavior_md: "Line one.\nLine two.",
behavior_revision: 3,
});
}) as typeof fetch;
const updated = await updateSubjektivSubjectBehavior(
fetchFn,
"workspace one",
"subject/one",
{ expected_behavior_revision: 2, behavior_md: "Line one.\nLine two." },
);
assertEquals(updated.behavior_revision, 3);
assertEquals(updated.behavior_md, "Line one.\nLine two.");
assertEquals(
requests[0].path,
"/api/w/workspace%20one/subjektiv/subjects/subject%2Fone/behavior",
);
assertEquals(requests[0].init?.method, "PATCH");
assertEquals(
requests[0].init?.body,
'{"expected_behavior_revision":2,"behavior_md":"Line one.\\nLine two."}',
);
});
Deno.test("Subject behavior update reports conflicts without claiming Worker application", async () => {
const conflictFetch =
(async () =>
Response.json({ message: "revision conflict" }, {
status: 409,
})) as typeof fetch;
await assertSubjectCreateRejects(
() =>
updateSubjektivSubjectBehavior(conflictFetch, "workspace", "subject", {
expected_behavior_revision: 1,
behavior_md: "new",
}),
"rejected",
"changed elsewhere",
409,
);
});
Deno.test("Subject create client sends the exact typed body once and parses the created Subject", async () => {
@@ -294,20 +361,26 @@ Deno.test("Subject create client sends the exact typed body once and parses the
return Response.json({
...subject("subject-created"),
role: " Release coordinator ",
behavior_md: "Stay concise.",
}, { status: 201 });
}) as typeof fetch;
const created = await createSubjektivSubject(fetchFn, "workspace one", {
role: " Release coordinator ",
behavior_md: "Stay concise.",
});
assertEquals(created, {
...subject("subject-created"),
role: " Release coordinator ",
behavior_md: "Stay concise.",
});
assertEquals(requests.length, 1);
assertEquals(requests[0].path, "/api/w/workspace%20one/subjektiv/subjects");
assertEquals(requests[0].init?.method, "POST");
assertEquals(requests[0].init?.body, '{"role":" Release coordinator "}');
assertEquals(
requests[0].init?.body,
'{"role":" Release coordinator ","behavior_md":"Stay concise."}',
);
});
Deno.test("Subject create client distinguishes rejected and unknown outcomes without retrying", async () => {