fix: reconcile T-697 generated contracts with latest develop
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user