Merge develop into T-693 and reconcile Subject behavior with Memory information
Normal merge of develop@b0bce50b retains T-692 state hierarchy, diagnostics disclosures, ready-empty and candidate-only regressions alongside T-693 behavior/CAS/route fences. ResidentContext load and refresh retain the five-second timeout from 685b8aa6, with a seven-request regression.
Validation: root cargo check; full worker(serial), session-store, workspace-server and server-api(typescript) suites; frontend check with zero diagnostics; 464 Web tests and 171 component tests (19 Memory); production Memory browser-shell test/build; fmt/diff/OpenAPI checks.
This commit is contained in:
@@ -33,6 +33,7 @@ import {
|
||||
parseWorkerLaunchOptionsResponse,
|
||||
parseWorkerSummary,
|
||||
} from "./workers.ts";
|
||||
import { loadJson } from "./http.ts";
|
||||
|
||||
const worker = {
|
||||
runtime_id: "runtime-a",
|
||||
@@ -111,6 +112,92 @@ Deno.test("Worker summary parser enforces observation freshness", () => {
|
||||
assertEquals(observedStopped.state, "stopped");
|
||||
});
|
||||
|
||||
const workerStates = [
|
||||
{ kind: "idle" },
|
||||
{ kind: "busy", state: { kind: "run", state: "running" } },
|
||||
{ kind: "busy", state: { kind: "run", state: "pausing" } },
|
||||
{ kind: "busy", state: { kind: "run", state: "paused" } },
|
||||
{ kind: "busy", state: { kind: "run", state: "cancelling" } },
|
||||
{ kind: "busy", state: { kind: "maintenance", state: "compacting" } },
|
||||
] as const;
|
||||
|
||||
Deno.test("Worker summary parser preserves the optional submission completion fence in every state", () => {
|
||||
for (const state of workerStates) {
|
||||
for (const completion of [
|
||||
{},
|
||||
{ last_finished_submission_request_id: null },
|
||||
{ last_finished_submission_request_id: "completed-submit" },
|
||||
]) {
|
||||
const snapshot = { last_command_id: 7, ...completion, state };
|
||||
const parsed = parseWorkerSummary({
|
||||
...worker,
|
||||
resource_key: "W-1",
|
||||
availability: "observed",
|
||||
worker_state: snapshot,
|
||||
});
|
||||
assertEquals(parsed.worker_state, snapshot);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Worker summary parser validates submission completion fences without accepting unknown fields", () => {
|
||||
for (const state of workerStates) {
|
||||
for (const invalid of [0, false, {}, [], "x".repeat(65_537)]) {
|
||||
assertThrows(
|
||||
() => parseWorkerSummary({
|
||||
...worker,
|
||||
resource_key: "W-1",
|
||||
availability: "observed",
|
||||
worker_state: {
|
||||
last_command_id: 7,
|
||||
last_finished_submission_request_id: invalid,
|
||||
state,
|
||||
},
|
||||
}),
|
||||
Error,
|
||||
"worker_state.last_finished_submission_request_id",
|
||||
);
|
||||
}
|
||||
assertThrows(
|
||||
() => parseWorkerSummary({
|
||||
...worker,
|
||||
resource_key: "W-1",
|
||||
availability: "observed",
|
||||
worker_state: {
|
||||
last_command_id: 7,
|
||||
last_finished_submission_request_id: "completed-submit",
|
||||
state,
|
||||
unexpected: true,
|
||||
},
|
||||
}),
|
||||
Error,
|
||||
"unknown field unexpected",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Console Worker API loading accepts a completed submission snapshot", async () => {
|
||||
const snapshot = {
|
||||
last_command_id: 0,
|
||||
last_finished_submission_request_id: "completed-submit",
|
||||
state: { kind: "idle" },
|
||||
};
|
||||
const result = await loadJson(
|
||||
() => Promise.resolve(Response.json({
|
||||
...worker,
|
||||
resource_key: "W-1",
|
||||
availability: "observed",
|
||||
worker_state: snapshot,
|
||||
})),
|
||||
"/api/w/workspace-a/workers/W-1",
|
||||
undefined,
|
||||
parseWorkerSummary,
|
||||
{ diagnosticLabel: "Worker API", maxResponseBytes: 8 * 1024 * 1024 },
|
||||
);
|
||||
assertEquals(result.error, null);
|
||||
assertEquals(result.data?.worker_state, snapshot);
|
||||
});
|
||||
|
||||
Deno.test("Worker summary parser preserves attachment-effective External permissions", () => {
|
||||
const attachment = {
|
||||
alias: "sessions",
|
||||
|
||||
@@ -262,7 +262,16 @@ function workerStateSnapshot(
|
||||
label: string,
|
||||
): WorkerStateSnapshot {
|
||||
const snapshot = record(value, label);
|
||||
exact(snapshot, ["last_command_id", "state"], label);
|
||||
exact(
|
||||
snapshot,
|
||||
["last_command_id", "last_finished_submission_request_id", "state"],
|
||||
label,
|
||||
);
|
||||
const lastFinishedSubmissionRequestId = optional(
|
||||
snapshot.last_finished_submission_request_id,
|
||||
`${label}.last_finished_submission_request_id`,
|
||||
string,
|
||||
);
|
||||
const state = record(snapshot.state, `${label}.state`);
|
||||
const kind = string(state.kind, `${label}.state.kind`);
|
||||
if (kind === "idle") {
|
||||
@@ -272,6 +281,7 @@ function workerStateSnapshot(
|
||||
snapshot.last_command_id,
|
||||
`${label}.last_command_id`,
|
||||
),
|
||||
last_finished_submission_request_id: lastFinishedSubmissionRequestId,
|
||||
state: { kind: "idle" },
|
||||
};
|
||||
}
|
||||
@@ -291,6 +301,7 @@ function workerStateSnapshot(
|
||||
snapshot.last_command_id,
|
||||
`${label}.last_command_id`,
|
||||
),
|
||||
last_finished_submission_request_id: lastFinishedSubmissionRequestId,
|
||||
state: {
|
||||
kind: "busy",
|
||||
state: busyKind === "run"
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import ConfigSourceEditor from "./ConfigSourceEditor.svelte";
|
||||
|
||||
const toolchain = vi.hoisted(() => ({
|
||||
construct: vi.fn(),
|
||||
setSnapshot: vi.fn(),
|
||||
analyze: vi.fn(),
|
||||
complete: vi.fn(),
|
||||
close: vi.fn(),
|
||||
}));
|
||||
vi.mock("./toolchain.ts", () => ({
|
||||
ConfigSourceToolchain: class {
|
||||
constructor() {
|
||||
toolchain.construct();
|
||||
}
|
||||
setSnapshot = toolchain.setSnapshot;
|
||||
analyze = toolchain.analyze;
|
||||
complete = toolchain.complete;
|
||||
close = toolchain.close;
|
||||
},
|
||||
}));
|
||||
|
||||
const schemaSource = "schema body\n".repeat(6000);
|
||||
const tree = {
|
||||
snapshot: {
|
||||
revision: 7,
|
||||
digest: "sha256:tree",
|
||||
entries: {
|
||||
"main.dcdl": {
|
||||
path: "main.dcdl",
|
||||
content_type: "decodal",
|
||||
content: "WorkspaceConfigSchema({})\n",
|
||||
content_digest: "sha256:main",
|
||||
},
|
||||
},
|
||||
},
|
||||
contract: {
|
||||
contract_version: 1,
|
||||
decodal_version: "0.4.0",
|
||||
schema_version: 1,
|
||||
entrypoints: ["main.dcdl"],
|
||||
import_policy_version: 1,
|
||||
schema_bundle: {
|
||||
contributions: [{
|
||||
provider_id: "builtin",
|
||||
namespace: "workspace",
|
||||
version: "1",
|
||||
source: schemaSource,
|
||||
source_digest: "sha256:contribution",
|
||||
}],
|
||||
source: schemaSource,
|
||||
fingerprint: "sha256:schema",
|
||||
},
|
||||
fingerprint: "sha256:toolchain",
|
||||
},
|
||||
projection_digest: "sha256:projection",
|
||||
};
|
||||
const fetcher = vi.fn();
|
||||
const response = () => new Response(JSON.stringify(tree));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
fetcher.mockImplementation(async () => response());
|
||||
toolchain.setSnapshot.mockResolvedValue(undefined);
|
||||
toolchain.analyze.mockResolvedValue([]);
|
||||
toolchain.complete.mockResolvedValue(null);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function renderEditor(workspaceId = "w") {
|
||||
return render(ConfigSourceEditor, { workspaceId });
|
||||
}
|
||||
|
||||
function expectNoEditor(view: ReturnType<typeof renderEditor>) {
|
||||
expect(view.queryByText("Select or create a source")).toBeNull();
|
||||
expect(view.queryByRole("button", { name: "Create local source" }))
|
||||
.toBeNull();
|
||||
expect(view.queryByRole("button", { name: "Commit" })).toBeNull();
|
||||
expect(view.container.querySelector(".cm-editor")).toBeNull();
|
||||
}
|
||||
|
||||
async function expectReady(view: ReturnType<typeof renderEditor>) {
|
||||
await waitFor(() =>
|
||||
expect(view.getByRole("button", { name: "main.dcdl entrypoint" })).not
|
||||
.toBeNull()
|
||||
);
|
||||
expect(view.container.querySelector('.cm-content[contenteditable="true"]'))
|
||||
.not.toBeNull();
|
||||
expect(view.container.textContent).toContain("WorkspaceConfigSchema({})");
|
||||
expect(view.queryByRole("alert")).toBeNull();
|
||||
}
|
||||
|
||||
test("loading is not an empty source tree; editing waits for schema installation", async () => {
|
||||
let finishFetch!: (response: Response) => void;
|
||||
let finishSetup!: () => void;
|
||||
fetcher.mockReturnValueOnce(
|
||||
new Promise<Response>((resolve) => {
|
||||
finishFetch = resolve;
|
||||
}),
|
||||
);
|
||||
toolchain.setSnapshot.mockReturnValueOnce(
|
||||
new Promise<void>((resolve) => {
|
||||
finishSetup = resolve;
|
||||
}),
|
||||
);
|
||||
const view = render(ConfigSourceEditor, { workspaceId: "w/one" });
|
||||
expect(view.getByRole("status").textContent).toContain("Loading source tree");
|
||||
expectNoEditor(view);
|
||||
finishFetch(response());
|
||||
await waitFor(() =>
|
||||
expect(toolchain.setSnapshot).toHaveBeenCalledWith(
|
||||
tree.snapshot,
|
||||
tree.contract.schema_bundle,
|
||||
)
|
||||
);
|
||||
expectNoEditor(view);
|
||||
finishSetup();
|
||||
await expectReady(view);
|
||||
expect(fetcher).toHaveBeenCalledWith(
|
||||
"/api/w/w%2Fone/config/source-tree",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
test.each(["http", "invalid"])(
|
||||
"%s failure has a manual reload, not creation controls or automatic retries",
|
||||
async (failure) => {
|
||||
fetcher.mockResolvedValueOnce(
|
||||
failure === "http"
|
||||
? new Response("Backend unavailable", { status: 503 })
|
||||
: new Response(JSON.stringify({ ...tree, unexpected: true })),
|
||||
);
|
||||
const view = renderEditor();
|
||||
await waitFor(() =>
|
||||
expect(view.getByRole("alert").textContent).toContain(
|
||||
"Unable to load workspace configuration",
|
||||
)
|
||||
);
|
||||
expect(view.getByRole("alert").textContent).toContain(
|
||||
failure === "http" ? "Backend unavailable" : "invalid response",
|
||||
);
|
||||
expectNoEditor(view);
|
||||
expect(toolchain.setSnapshot).not.toHaveBeenCalled();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
expect(fetcher).toHaveBeenCalledOnce();
|
||||
await fireEvent.click(
|
||||
view.getByRole("button", { name: "Reload source tree" }),
|
||||
);
|
||||
await expectReady(view);
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["construct", "setSnapshot"] as const)(
|
||||
"toolchain %s failure is caught and manual reload initializes a fresh toolchain",
|
||||
async (phase) => {
|
||||
if (phase === "construct") {
|
||||
toolchain.construct.mockImplementationOnce(() => {
|
||||
throw new Error("Worker unavailable");
|
||||
});
|
||||
} else {toolchain.setSnapshot.mockRejectedValueOnce(
|
||||
new Error("Schema setup failed"),
|
||||
);}
|
||||
const view = renderEditor();
|
||||
await waitFor(() =>
|
||||
expect(view.getByRole("alert").textContent).toContain(
|
||||
phase === "construct" ? "Worker unavailable" : "Schema setup failed",
|
||||
)
|
||||
);
|
||||
expectNoEditor(view);
|
||||
await fireEvent.click(
|
||||
view.getByRole("button", { name: "Reload source tree" }),
|
||||
);
|
||||
await expectReady(view);
|
||||
expect(toolchain.construct).toHaveBeenCalledTimes(2);
|
||||
},
|
||||
);
|
||||
|
||||
test("a successfully loaded empty tree still offers source creation", async () => {
|
||||
fetcher.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({
|
||||
...tree,
|
||||
snapshot: { ...tree.snapshot, entries: {} },
|
||||
})),
|
||||
);
|
||||
const view = renderEditor();
|
||||
await waitFor(() =>
|
||||
expect(view.getByText("Select or create a source")).not.toBeNull()
|
||||
);
|
||||
expect(view.getByRole("button", { name: "Create local source" })).not
|
||||
.toBeNull();
|
||||
expect(view.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
test("unmount during schema setup closes the toolchain", async () => {
|
||||
let failSetup!: (error: Error) => void;
|
||||
toolchain.setSnapshot.mockReturnValueOnce(
|
||||
new Promise<void>((_, reject) => {
|
||||
failSetup = reject;
|
||||
}),
|
||||
);
|
||||
toolchain.close.mockImplementationOnce(() =>
|
||||
failSetup(new Error("toolchain closed"))
|
||||
);
|
||||
const view = renderEditor();
|
||||
await waitFor(() => expect(toolchain.setSnapshot).toHaveBeenCalledOnce());
|
||||
view.unmount();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(toolchain.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("unmount during fetch does not start a toolchain", async () => {
|
||||
let finishFetch!: (response: Response) => void;
|
||||
fetcher.mockReturnValueOnce(
|
||||
new Promise<Response>((resolve) => {
|
||||
finishFetch = resolve;
|
||||
}),
|
||||
);
|
||||
const view = renderEditor();
|
||||
view.unmount();
|
||||
finishFetch(response());
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(toolchain.construct).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -21,6 +21,8 @@
|
||||
let newPath = $state("module.dcdl");
|
||||
let diagnostics = $state<ConfigDiagnostic[]>([]);
|
||||
let status = $state("Loading source tree…");
|
||||
let loadState = $state<"loading" | "error" | "ready">("loading");
|
||||
let mounted = false;
|
||||
let busy = $state(false);
|
||||
let workingChanges = $state<ConfigTreeChange[]>([]);
|
||||
let baseRevision = $state(0);
|
||||
@@ -42,9 +44,12 @@
|
||||
const dirty = $derived(workingChanges.length > 0 || (selected ? source !== selected.content : source.length > 0));
|
||||
|
||||
onMount(() => {
|
||||
toolchain = new ConfigSourceToolchain();
|
||||
mounted = true;
|
||||
void reload();
|
||||
return () => toolchain?.close();
|
||||
return () => {
|
||||
mounted = false;
|
||||
toolchain?.close();
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
@@ -70,9 +75,20 @@
|
||||
});
|
||||
|
||||
async function reload() {
|
||||
loadState = "loading";
|
||||
status = "Loading source tree…";
|
||||
analysisReady = false;
|
||||
analysisGeneration += 1;
|
||||
try {
|
||||
treeState = await fetchConfigTree(workspaceId);
|
||||
const remote = await fetchConfigTree(workspaceId);
|
||||
if (!mounted) return;
|
||||
toolchain?.close();
|
||||
toolchain = null;
|
||||
toolchain = new ConfigSourceToolchain();
|
||||
await toolchain.setSnapshot(remote.snapshot, remote.contract.schema_bundle);
|
||||
if (!mounted) return;
|
||||
// Publish the new baseline only after both fetch and toolchain setup succeed.
|
||||
treeState = remote;
|
||||
if (!selectedPath || !treeState.snapshot.entries[selectedPath]) {
|
||||
selectedPath = Object.keys(treeState.snapshot.entries).toSorted()[0] ?? "";
|
||||
}
|
||||
@@ -80,15 +96,19 @@
|
||||
baseSnapshot = $state.snapshot(treeState.snapshot);
|
||||
baseRevision = treeState.snapshot.revision;
|
||||
baseDigest = treeState.snapshot.digest;
|
||||
await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle);
|
||||
analysisReady = true;
|
||||
workingChanges = [];
|
||||
renamePath = selectedPath;
|
||||
diagnostics = [];
|
||||
conflict = false;
|
||||
status = `Revision ${treeState.snapshot.revision} · ${treeState.snapshot.digest.slice(0, 20)}…`;
|
||||
loadState = "ready";
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
toolchain?.close();
|
||||
toolchain = null;
|
||||
status = String(error);
|
||||
loadState = "error";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,8 +233,6 @@
|
||||
}
|
||||
|
||||
async function discardAndReload() {
|
||||
workingChanges = [];
|
||||
source = "";
|
||||
await reload();
|
||||
}
|
||||
|
||||
@@ -287,7 +305,18 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="config-source-shell" aria-label="Workspace configuration source tree">
|
||||
<section class="config-source-shell" aria-label="Workspace configuration source tree" aria-busy={loadState === "loading"}>
|
||||
{#if loadState === "loading"}
|
||||
<div class="config-source-load" role="status">Loading source tree…</div>
|
||||
{:else if loadState === "error"}
|
||||
<div class="config-source-load">
|
||||
<div role="alert">
|
||||
<strong>Unable to load workspace configuration</strong>
|
||||
<p>{status}</p>
|
||||
</div>
|
||||
<button type="button" onclick={reload}>Reload source tree</button>
|
||||
</div>
|
||||
{:else}
|
||||
<aside class="config-source-tree">
|
||||
<div class="config-source-tree__header">
|
||||
<strong>Source tree</strong>
|
||||
@@ -354,4 +383,12 @@
|
||||
</ol>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.config-source-load {
|
||||
grid-column: 1 / -1;
|
||||
padding: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,6 +11,9 @@ import type {
|
||||
} from "./types.ts";
|
||||
|
||||
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
||||
// Schema bodies are documents, not short metadata. Keep them within the
|
||||
// response budget; readJson also bounds the combined bundle and contributions.
|
||||
const MAX_SCHEMA_SOURCE_BYTES = MAX_RESPONSE_BYTES;
|
||||
const MAX_ERROR_BYTES = 4 * 1024;
|
||||
const MAX_ENTRY_COUNT = 256;
|
||||
const MAX_ENTRY_BYTES = 256 * 1024;
|
||||
@@ -193,7 +196,7 @@ function parseContribution(value: unknown): ConfigSchemaContribution {
|
||||
provider_id: boundedString(item.provider_id),
|
||||
namespace: boundedString(item.namespace),
|
||||
version: boundedString(item.version),
|
||||
source: boundedString(item.source),
|
||||
source: boundedString(item.source, MAX_SCHEMA_SOURCE_BYTES),
|
||||
...(projection === undefined ? {} : { projection_validator: projection }),
|
||||
source_digest: boundedString(item.source_digest, MAX_DIGEST_BYTES),
|
||||
};
|
||||
@@ -203,7 +206,7 @@ function parseSchemaBundle(value: unknown): WorkspaceConfigSchemaBundle {
|
||||
const item = record(value, ["contributions", "source", "fingerprint"]);
|
||||
return {
|
||||
contributions: boundedArray(item.contributions).map(parseContribution),
|
||||
source: boundedString(item.source),
|
||||
source: boundedString(item.source, MAX_SCHEMA_SOURCE_BYTES),
|
||||
fingerprint: boundedString(item.fingerprint, MAX_DIGEST_BYTES),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,23 @@ import WorkerContextStatus from "./WorkerContextStatus.svelte";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
test("the three-dot Details control invokes the existing toggle and reflects its state", async () => {
|
||||
let toggles = 0;
|
||||
const view = render(WorkerContextStatus, {
|
||||
metadata: null,
|
||||
detailsOpen: false,
|
||||
onToggleDetails: () => { toggles++; },
|
||||
});
|
||||
const button = view.getByRole("button", { name: "Details" });
|
||||
expect(button.querySelectorAll("circle")).toHaveLength(3);
|
||||
expect(button.textContent?.trim()).toBe("");
|
||||
expect(button.getAttribute("aria-expanded")).toBe("false");
|
||||
button.click();
|
||||
expect(toggles).toBe(1);
|
||||
await view.rerender({ detailsOpen: true });
|
||||
expect(button.getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
|
||||
test("renders resolved model, reasoning, and measured context accessibly", () => {
|
||||
const view = render(WorkerContextStatus, {
|
||||
metadata: {
|
||||
@@ -19,13 +36,13 @@ test("renders resolved model, reasoning, and measured context accessibly", () =>
|
||||
|
||||
const status = view.getByLabelText("Worker model and context");
|
||||
expect(status.textContent).toContain("gpt-6-astra · high");
|
||||
expect(status.textContent).toContain("Context 142.0k / 272.0k (52%)");
|
||||
expect(status.textContent).toContain("142.0k / 272.0k (52%)");
|
||||
});
|
||||
|
||||
test("renders honest unavailable state instead of a fabricated zero percent", async () => {
|
||||
const view = render(WorkerContextStatus, { metadata: null });
|
||||
expect(view.getByText("Model unavailable")).not.toBeNull();
|
||||
expect(view.getByText("Context unavailable")).not.toBeNull();
|
||||
expect(view.getByText("unavailable")).not.toBeNull();
|
||||
expect(view.container.textContent).not.toContain("0%");
|
||||
|
||||
await view.rerender({
|
||||
@@ -38,7 +55,7 @@ test("renders honest unavailable state instead of a fabricated zero percent", as
|
||||
},
|
||||
});
|
||||
expect(view.container.textContent).toContain("claude · 8.2k token budget");
|
||||
expect(view.container.textContent).toContain("Context ~12.0k / 64.0k (19%)");
|
||||
expect(view.container.textContent).toContain("~12.0k / 64.0k (19%)");
|
||||
|
||||
await view.rerender({
|
||||
metadata: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import type { ConsoleWorkerMetadata } from "./model";
|
||||
import {
|
||||
formatContextSummary,
|
||||
@@ -7,23 +8,51 @@
|
||||
|
||||
type Props = {
|
||||
metadata: ConsoleWorkerMetadata | null;
|
||||
controls?: Snippet;
|
||||
detailsOpen?: boolean;
|
||||
onToggleDetails?: () => void;
|
||||
};
|
||||
|
||||
let { metadata }: Props = $props();
|
||||
let { metadata, controls, detailsOpen = false, onToggleDetails }: Props = $props();
|
||||
const modelSummary = $derived(formatModelSummary(metadata));
|
||||
const contextSummary = $derived(formatContextSummary(metadata));
|
||||
</script>
|
||||
|
||||
<div class="worker-context-status" aria-label="Worker model and context">
|
||||
<span class="worker-model" title={modelSummary}>{modelSummary}</span>
|
||||
<span class="separator" aria-hidden="true">|</span>
|
||||
<span class="separator" aria-hidden="true">·</span>
|
||||
<span class="worker-context" title={contextSummary}>{contextSummary}</span>
|
||||
{#if controls || onToggleDetails}
|
||||
<div class="worker-context-actions">
|
||||
{@render controls?.()}
|
||||
{#if onToggleDetails}
|
||||
<button
|
||||
type="button"
|
||||
class="details-button"
|
||||
aria-label="Details"
|
||||
aria-expanded={detailsOpen}
|
||||
onclick={onToggleDetails}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="5" cy="12" r="2" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
<circle cx="19" cy="12" r="2" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.worker-context-status {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: nowrap;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
padding-inline: var(--space-3);
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-muted);
|
||||
@@ -50,20 +79,44 @@
|
||||
color: var(--line-strong);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.worker-context-status {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 0;
|
||||
}
|
||||
.worker-context-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.separator {
|
||||
display: none;
|
||||
}
|
||||
.details-button {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--space-5);
|
||||
height: var(--space-5);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--space-1);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.worker-model,
|
||||
.worker-context {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
.details-button:hover,
|
||||
.details-button[aria-expanded="true"] {
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.details-button:focus-visible {
|
||||
color: var(--text-strong);
|
||||
outline: 1px solid currentColor;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.details-button svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: currentColor;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3769,6 +3769,29 @@ Deno.test("committed tool identities reconcile one live call block", () => {
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("image rejection correction updates the existing tool result without duplicate rows", () => {
|
||||
const result: SessionSnapshotEntry = {
|
||||
kind: "tool_result", entry_id: "result-image", timestamp: 2,
|
||||
provenance: "tool_output", call_id: "image", summary: "Attached image", is_error: false,
|
||||
attachments: [{ attachment_id: "image-body", media_type: "image/png", byte_len: 100 }],
|
||||
};
|
||||
const lines = projectConsole([
|
||||
{ eventId: "call", event: { event: "session_entry_committed", data: { entry: {
|
||||
kind: "tool_call", entry_id: "call-image", timestamp: 1, provenance: "model_output",
|
||||
call_id: "image", name: "ViewImage", arguments: "{}",
|
||||
} } } },
|
||||
{ eventId: "original", event: { event: "session_entry_committed", data: { entry: result } } },
|
||||
{ eventId: "correction", event: { event: "session_entry_committed", data: { entry: {
|
||||
...result, timestamp: 3, summary: "Resize or crop and retry ViewImage", is_error: true, attachments: [],
|
||||
} } } },
|
||||
]).lines;
|
||||
assertEquals(lines.length, 1);
|
||||
assertEquals(lines[0].entryId, "result-image");
|
||||
assertEquals(lines[0].toolCall?.state, "error");
|
||||
assertEquals(lines[0].attachments ?? [], []);
|
||||
assert(lines[0].body.includes("Resize or crop"), "show the model recovery instruction");
|
||||
});
|
||||
|
||||
function readHistory(ids: string[], summary: string): ConsoleLine[] {
|
||||
const entries = ids.flatMap((id): SessionSnapshotEntry[] => [{
|
||||
kind: "tool_call", entry_id: `call-entry-${id}`, timestamp: 1,
|
||||
|
||||
@@ -45,7 +45,7 @@ Deno.test("worker metadata formats effort, budget, estimate, and unknown safely"
|
||||
contextTokens: 142_000,
|
||||
contextSource: "estimated",
|
||||
}),
|
||||
"Context ~142.0k / 272.0k (52%)",
|
||||
"~142.0k / 272.0k (52%)",
|
||||
);
|
||||
assertEquals(formatModelSummary(null), "Model unavailable");
|
||||
assertEquals(
|
||||
@@ -64,7 +64,7 @@ Deno.test("worker metadata formats effort, budget, estimate, and unknown safely"
|
||||
formatReasoning({ kind: "budget_tokens", budget_tokens: -2 }),
|
||||
"reasoning unavailable",
|
||||
);
|
||||
assertEquals(formatContextSummary(null), "Context unavailable");
|
||||
assertEquals(formatContextSummary(null), "unavailable");
|
||||
assertEquals(
|
||||
formatContextSummary({
|
||||
model: null,
|
||||
@@ -73,7 +73,7 @@ Deno.test("worker metadata formats effort, budget, estimate, and unknown safely"
|
||||
contextTokens: 0,
|
||||
contextSource: null,
|
||||
}),
|
||||
"Context unavailable",
|
||||
"unavailable",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -502,7 +502,9 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async ()
|
||||
subjectIndexLoad.includes('"/subjektiv/subjects"') &&
|
||||
subjectIndexLoad.includes("parseSubjektivSubjectListResponse") &&
|
||||
subjectIndexPage.includes(">Subjects</h1>") &&
|
||||
subjectIndexPage.includes("subject.store_revision") &&
|
||||
subjectIndexPage.includes("Worker connection") &&
|
||||
subjectIndexPage.includes("subject.current_worker") &&
|
||||
!subjectIndexPage.includes("subject.store_revision") &&
|
||||
subjectIndexPage.includes('data-memory-view="subjects"'),
|
||||
"Memory product entry should list explicit typed subjects",
|
||||
);
|
||||
@@ -510,10 +512,13 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async ()
|
||||
subjectPageLoad.includes("`${subjectPath}/surface`") &&
|
||||
subjectPageLoad.includes("`${subjectPath}/memories`") &&
|
||||
subjectPage.includes("DocumentMarkdown from") &&
|
||||
subjectPage.includes("Resident surface") &&
|
||||
subjectPage.includes("Resident context") &&
|
||||
subjectPage.includes("Subject store revision") &&
|
||||
subjectPage.includes("Current Memories") &&
|
||||
subjectPage.includes("surface?.availability === 'ready'") &&
|
||||
subjectPage.includes("surface?.availability === 'ungenerated'") &&
|
||||
subjectPage.includes("surface?.availability === 'stale'") &&
|
||||
subjectPage.includes("surface?.availability === 'failed'") &&
|
||||
subjectPage.includes("data-surface-ready-empty") &&
|
||||
!subjectPage.includes("overflow-y"),
|
||||
"Subject page should combine strict resident-surface and current-Memory reads without a nested vertical scroller",
|
||||
@@ -738,6 +743,32 @@ Deno.test("Worker Console renders Edit diffs without preformatted template gaps"
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Model information stays below Composer in one compact monospace row", async () => {
|
||||
const page = await Deno.readTextFile(new URL(
|
||||
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
|
||||
import.meta.url,
|
||||
));
|
||||
const composer = page.indexOf('<form class="console-composer"');
|
||||
const composerEnd = page.indexOf("</form>", composer);
|
||||
const status = page.indexOf("<WorkerContextStatus");
|
||||
assert(composer >= 0 && composerEnd > composer && status > composerEnd, "Model information must follow Composer");
|
||||
assert(page.includes("metadata={selectedConsoleProjection.workerMetadata}"), "Keep selected Worker metadata");
|
||||
assert(page.indexOf("<WorkerContextStatus", status + 1) === -1, "Render model information only once");
|
||||
const component = await Deno.readTextFile(new URL("./WorkerContextStatus.svelte", import.meta.url));
|
||||
for (const rule of [
|
||||
"flex-wrap: nowrap", "white-space: nowrap", "text-overflow: ellipsis",
|
||||
"font-family: var(--font-mono)", "font-size: var(--font-size-compact)",
|
||||
"line-height: var(--line-height-compact)", "padding-inline: var(--space-3)",
|
||||
]) assert(component.includes(rule), rule);
|
||||
assert(!component.includes("@media"), "Narrow layouts must retain the same single row");
|
||||
const modes = page.indexOf('class="console-view-toggle"');
|
||||
assert(modes > status && modes < page.indexOf("</WorkerContextStatus>", status), "Display toggle must be in the model information row");
|
||||
assert(!page.includes("console-header"), "Remove the empty header and its styles");
|
||||
assert(component.indexOf("{@render controls?.()}") < component.indexOf('class="details-button"'), "Display controls must precede Details");
|
||||
assert(/\.worker-context-actions\s*\{[^}]*flex: 0 0 auto/.test(component), "Keep display controls from shrinking on narrow screens");
|
||||
assert(component.includes("margin-left: auto"), "Align footer controls to the right");
|
||||
});
|
||||
|
||||
Deno.test("Console spacing and text metrics use existing design tokens", async () => {
|
||||
const files = [
|
||||
"ComposerInput.svelte",
|
||||
@@ -1551,14 +1582,10 @@ Deno.test("Composer owns TUI-style completion and replaces Compact/Rewind header
|
||||
const input = await Deno.readTextFile(
|
||||
new URL("./ComposerInput.svelte", import.meta.url),
|
||||
);
|
||||
const header = page.slice(
|
||||
page.indexOf('<section class="console-header'),
|
||||
page.indexOf("{#if rewindTargets.length"),
|
||||
);
|
||||
assert(
|
||||
!header.includes("Compact") && !header.includes("Rewind") &&
|
||||
!page.includes('class="console-header') &&
|
||||
!page.includes("requestRewindTargets"),
|
||||
"Header must defer Compact/Rewind to commands",
|
||||
"Compact/Rewind must remain commands without a dedicated header",
|
||||
);
|
||||
assert(
|
||||
input.includes('role="listbox"') && input.includes('role="option"') &&
|
||||
@@ -1588,12 +1615,8 @@ Deno.test("mini task summary owns pane toggling instead of the header", async ()
|
||||
const component = await Deno.readTextFile(
|
||||
new URL("./ConsoleTasks.svelte", import.meta.url),
|
||||
);
|
||||
const header = page.slice(
|
||||
page.indexOf('<section class="console-header'),
|
||||
page.indexOf("{#if rewindTargets.length"),
|
||||
);
|
||||
assert(
|
||||
!header.includes("taskPaneOpen = !taskPaneOpen") &&
|
||||
!page.includes('class="console-header') &&
|
||||
page.includes("onTogglePane={() => {"),
|
||||
"Only the mini summary should own Tasks toggling",
|
||||
);
|
||||
|
||||
@@ -36,11 +36,11 @@ export function formatContextSummary(
|
||||
used === null || used === undefined || limit === null ||
|
||||
limit === undefined || !Number.isSafeInteger(used) ||
|
||||
!Number.isSafeInteger(limit) || used < 0 || limit <= 0
|
||||
) return "Context unavailable";
|
||||
) return "unavailable";
|
||||
const percent = Math.round((used / limit) * 100);
|
||||
if (!Number.isFinite(percent)) return "Context unavailable";
|
||||
if (!Number.isFinite(percent)) return "unavailable";
|
||||
const estimate = metadata?.contextSource === "estimated" ? "~" : "";
|
||||
return `Context ${estimate}${formatRunTokens(used)} / ${
|
||||
return `${estimate}${formatRunTokens(used)} / ${
|
||||
formatRunTokens(limit)
|
||||
} (${percent}%)`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import {
|
||||
type CompletionResult,
|
||||
completionStatus,
|
||||
startCompletion,
|
||||
} from "@codemirror/autocomplete";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import DecodalSourceEditor from "./DecodalSourceEditor.svelte";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function setup(
|
||||
value: string,
|
||||
props: {
|
||||
readonly?: boolean;
|
||||
fixedSchemaWrapper?: boolean;
|
||||
onComplete?: (
|
||||
source: string,
|
||||
offset: number,
|
||||
explicit: boolean,
|
||||
) => Promise<CompletionResult | null>;
|
||||
} = {},
|
||||
) {
|
||||
const onChange = vi.fn();
|
||||
const result = render(DecodalSourceEditor, { value, onChange, ...props });
|
||||
const content = result.container.querySelector<HTMLElement>(".cm-content")!;
|
||||
const editor = EditorView.findFromDOM(content)!;
|
||||
editor.focus();
|
||||
return { ...result, content, editor, onChange };
|
||||
}
|
||||
|
||||
test.each([
|
||||
{ name: "plain line", value: "name = 1", anchor: 8, expected: "name = 1\n" },
|
||||
{
|
||||
name: "object body",
|
||||
value: "{\n name = 1",
|
||||
anchor: 12,
|
||||
expected: "{\n name = 1\n ",
|
||||
},
|
||||
{ name: "empty object", value: "{}", anchor: 1, expected: "{\n \n}" },
|
||||
{
|
||||
name: "nested object",
|
||||
value: "{\n nested = {}\n}",
|
||||
anchor: 14,
|
||||
expected: "{\n nested = {\n \n }\n}",
|
||||
},
|
||||
])(
|
||||
"Enter inserts an indented newline in $name",
|
||||
async ({ value, anchor, expected }) => {
|
||||
const { content, editor, onChange } = setup(value);
|
||||
editor.dispatch({ selection: { anchor } });
|
||||
expect(await fireEvent.keyDown(content, { key: "Enter", code: "Enter" }))
|
||||
.toBe(false);
|
||||
expect(editor.state.doc.toString()).toBe(expected);
|
||||
expect(onChange).toHaveBeenLastCalledWith(expected);
|
||||
},
|
||||
);
|
||||
|
||||
test("Enter indents inside the fixed wrapper without altering its assertion", async () => {
|
||||
const { content, editor } = setup("{} as WorkspaceConfigSchema\n", {
|
||||
fixedSchemaWrapper: true,
|
||||
});
|
||||
await fireEvent.keyDown(content, { key: "Enter", code: "Enter" });
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
"{\n \n} as WorkspaceConfigSchema\n",
|
||||
);
|
||||
expect(editor.state.selection.main.head).toBe(4);
|
||||
});
|
||||
|
||||
test("Enter does not mutate readonly sources", async () => {
|
||||
const value = "{\n name = 1";
|
||||
const { content, editor, onChange } = setup(value, { readonly: true });
|
||||
editor.dispatch({ selection: { anchor: value.length } });
|
||||
await fireEvent.keyDown(content, { key: "Enter", code: "Enter" });
|
||||
expect(editor.state.doc.toString()).toBe(value);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("Enter adds an indented newline instead of accepting a visible completion", async () => {
|
||||
const value = "{\n na";
|
||||
const { content, editor } = setup(value, {
|
||||
onComplete: async () => ({
|
||||
from: 4,
|
||||
options: [{ label: "name", type: "property" }],
|
||||
}),
|
||||
});
|
||||
editor.dispatch({ selection: { anchor: value.length } });
|
||||
startCompletion(editor);
|
||||
await waitFor(() => expect(completionStatus(editor.state)).toBe("active"));
|
||||
// Wait past CodeMirror's completion interaction delay, so accepting a
|
||||
// suggestion would be observable rather than ignored as an early keypress.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await fireEvent.keyDown(content, { key: "Enter", code: "Enter" });
|
||||
expect(editor.state.doc.toString()).toBe("{\n na\n ");
|
||||
await waitFor(() => expect(completionStatus(editor.state)).toBeNull());
|
||||
});
|
||||
|
||||
test("Tab indents the current line and Shift+Tab removes indentation", async () => {
|
||||
const { content, editor, onChange } = setup("name = 1");
|
||||
editor.dispatch({ selection: { anchor: 4 } });
|
||||
expect(await fireEvent.keyDown(content, { key: "Tab", code: "Tab" })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(editor.state.doc.toString()).toBe(" name = 1");
|
||||
expect(onChange).toHaveBeenLastCalledWith(" name = 1");
|
||||
expect(editor.hasFocus).toBe(true);
|
||||
await fireEvent.keyDown(content, { key: "Tab", code: "Tab", shiftKey: true });
|
||||
expect(editor.state.doc.toString()).toBe("name = 1");
|
||||
expect(onChange).toHaveBeenLastCalledWith("name = 1");
|
||||
});
|
||||
|
||||
test("Tab and Shift+Tab indent every selected line", async () => {
|
||||
const value = "first = 1\nsecond = 2";
|
||||
const { content, editor } = setup(value);
|
||||
editor.dispatch({ selection: { anchor: 0, head: value.length } });
|
||||
await fireEvent.keyDown(content, { key: "Tab", code: "Tab" });
|
||||
expect(editor.state.doc.toString()).toBe(" first = 1\n second = 2");
|
||||
await fireEvent.keyDown(content, { key: "Tab", code: "Tab", shiftKey: true });
|
||||
expect(editor.state.doc.toString()).toBe(value);
|
||||
});
|
||||
|
||||
test("Tab inserts indentation on a blank line", async () => {
|
||||
const { content, editor } = setup("");
|
||||
await fireEvent.keyDown(content, { key: "Tab", code: "Tab" });
|
||||
expect(editor.state.doc.toString()).toBe(" ");
|
||||
});
|
||||
|
||||
test("Tab and Shift+Tab do not mutate readonly sources", async () => {
|
||||
const value = " name = 1";
|
||||
const { content, editor, onChange } = setup(value, { readonly: true });
|
||||
await fireEvent.keyDown(content, { key: "Tab", code: "Tab" });
|
||||
await fireEvent.keyDown(content, { key: "Tab", code: "Tab", shiftKey: true });
|
||||
expect(editor.state.doc.toString()).toBe(value);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("Tab indents the schema body without modifying the fixed wrapper", async () => {
|
||||
const value = "{\n name = 1\n} as WorkspaceConfigSchema\n";
|
||||
const { content, editor } = setup(value, { fixedSchemaWrapper: true });
|
||||
editor.dispatch({ selection: { anchor: value.indexOf("name") } });
|
||||
await fireEvent.keyDown(content, { key: "Tab", code: "Tab" });
|
||||
expect(editor.state.doc.toString()).toBe(
|
||||
"{\n name = 1\n} as WorkspaceConfigSchema\n",
|
||||
);
|
||||
await fireEvent.keyDown(content, { key: "Tab", code: "Tab", shiftKey: true });
|
||||
expect(editor.state.doc.toString()).toBe(value);
|
||||
editor.dispatch({ selection: { anchor: 0 } });
|
||||
await fireEvent.keyDown(content, { key: "Tab", code: "Tab" });
|
||||
expect(editor.state.doc.toString()).toBe(value);
|
||||
});
|
||||
@@ -9,6 +9,7 @@
|
||||
type CompletionContext,
|
||||
type CompletionResult,
|
||||
} from '@codemirror/autocomplete';
|
||||
import { indentWithTab, insertNewlineAndIndent } from '@codemirror/commands';
|
||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||
import { Compartment, EditorState } from '@codemirror/state';
|
||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection, type ViewUpdate } from '@codemirror/view';
|
||||
@@ -129,6 +130,8 @@
|
||||
decodal({ highlight: false }),
|
||||
syntaxHighlighting(syntaxTheme),
|
||||
autocompletion({
|
||||
// Use the explicit keymap below so Enter never accepts a suggestion.
|
||||
defaultKeymap: false,
|
||||
activateOnTyping: false,
|
||||
override: [
|
||||
async (context: CompletionContext) => {
|
||||
@@ -139,6 +142,10 @@
|
||||
],
|
||||
}),
|
||||
keymap.of(completionKeymapWithoutEnter),
|
||||
keymap.of([
|
||||
{ key: 'Enter', run: insertNewlineAndIndent },
|
||||
indentWithTab,
|
||||
]),
|
||||
fixedSchemaWrapperCompartment.of(
|
||||
initialFixedSchemaWrapper ? fixedSchemaWrapperExtension() : [],
|
||||
),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, type Snippet } from 'svelte';
|
||||
import { onDestroy, onMount, type Snippet } from 'svelte';
|
||||
import { on } from 'svelte/events';
|
||||
import Bevel from '#lib/workspace/ui/Bevel.svelte';
|
||||
import SidebarToggleIcon from './SidebarToggleIcon.svelte';
|
||||
import './sidebar.css';
|
||||
@@ -77,6 +78,19 @@
|
||||
focusInside = false;
|
||||
clearCloseTimer();
|
||||
});
|
||||
function closeAfterNavigation(event: MouseEvent) {
|
||||
if (!mobile || !open || event.defaultPrevented || event.button !== 0 ||
|
||||
event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||
const link = event.target instanceof Element ? event.target.closest('a[href]') : null;
|
||||
if (!(link instanceof HTMLAnchorElement) || !frame.contains(link) ||
|
||||
link.hasAttribute('download') || (link.target && link.target !== '_self')) return;
|
||||
clearCloseTimer();
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
// Handle nested navigation links, including activation of the current page.
|
||||
// Svelte's listener preserves ordering with delegated child click handlers.
|
||||
onMount(() => on(frame, 'click', closeAfterNavigation));
|
||||
onDestroy(clearCloseTimer);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, fireEvent, render } from "@testing-library/svelte";
|
||||
import { createRawSnippet } from "svelte";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import SidebarFrame from "./SidebarFrame.svelte";
|
||||
|
||||
// Stop happy-dom navigation after the component has handled the bubbling click.
|
||||
const preventBrowserNavigation = (event: MouseEvent) => event.preventDefault();
|
||||
beforeEach(() => document.addEventListener("click", preventBrowserNavigation));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
document.removeEventListener("click", preventBrowserNavigation);
|
||||
});
|
||||
|
||||
function mount(mobile = true, mode: "pinned" | "hover" = "pinned") {
|
||||
const onOpenChange = vi.fn();
|
||||
const onModeChange = vi.fn();
|
||||
const children = createRawSnippet(() => ({
|
||||
render: () => `<nav>
|
||||
<a href="/w/example"><span>Workspace home</span></a>
|
||||
<a href="${location.href}">Current page</a>
|
||||
<a href="#section">Section</a>
|
||||
<a href="/account" target="_blank">New tab</a>
|
||||
<a href="/export" download>Download</a>
|
||||
<button type="button">Expand group</button>
|
||||
</nav>`,
|
||||
}));
|
||||
return { ...render(SidebarFrame, {
|
||||
mobile, mode, open: true, children, onOpenChange, onModeChange,
|
||||
}), onOpenChange, onModeChange };
|
||||
}
|
||||
|
||||
test.each(["pinned", "hover"] as const)("mobile %s closes on nested, current-page and hash links", async (mode) => {
|
||||
const view = mount(true, mode);
|
||||
for (const label of ["Workspace home", "Current page", "Section"]) {
|
||||
view.onOpenChange.mockClear();
|
||||
await fireEvent.click(view.getByText(label));
|
||||
expect(view.onOpenChange).toHaveBeenCalledExactlyOnceWith(false);
|
||||
expect(view.onModeChange).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
test("mobile ignores disclosure buttons, modified clicks, new tabs, downloads and cancelled navigation", async () => {
|
||||
const view = mount();
|
||||
for (const label of ["Expand group", "New tab", "Download"]) {
|
||||
await fireEvent.click(view.getByText(label));
|
||||
}
|
||||
const link = view.getByText("Workspace home");
|
||||
for (const options of [{ ctrlKey: true }, { metaKey: true }, { shiftKey: true }, { altKey: true }, { button: 1 }]) {
|
||||
await fireEvent.click(link, options);
|
||||
}
|
||||
link.addEventListener("click", (event) => event.preventDefault(), { once: true });
|
||||
await fireEvent.click(link);
|
||||
expect(view.onOpenChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each(["pinned", "hover"] as const)("desktop %s navigation does not change sidebar mode or open state", async (mode) => {
|
||||
const view = mount(false, mode);
|
||||
await fireEvent.click(view.getByText("Workspace home"));
|
||||
expect(view.onOpenChange).not.toHaveBeenCalled();
|
||||
expect(view.onModeChange).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -34,6 +34,10 @@
|
||||
return cursor ? `${path}?cursor=${encodeURIComponent(cursor)}` : path;
|
||||
}
|
||||
|
||||
function subjectStateLabel(state: SubjektivSubjectResponse['state']): string {
|
||||
return state === 'active' ? 'Available' : 'Retired';
|
||||
}
|
||||
|
||||
async function openCreateForm(): Promise<void> {
|
||||
createExpanded = true;
|
||||
createError = null;
|
||||
@@ -121,7 +125,7 @@
|
||||
</div>
|
||||
<div class="memory-header-actions">
|
||||
{#if data.subjects.data}
|
||||
<span class="memory-count">{subjects.length}{data.subjects.data.has_more ? '+' : ''} subject{subjects.length === 1 ? '' : 's'}</span>
|
||||
<span class="memory-count">{subjects.length} shown{data.subjects.data.has_more ? ' · more available' : ''}</span>
|
||||
{/if}
|
||||
<button
|
||||
bind:this={createButton}
|
||||
@@ -209,14 +213,19 @@
|
||||
{#each subjects as subject (subject.id)}
|
||||
<a class="subject-row" href={subjectHref(subject.id)}>
|
||||
<div class="subject-copy">
|
||||
<span class="memory-state-pill is-{subject.state}">{subject.state}</span>
|
||||
<span class="memory-state-pill is-{subject.state}">{subjectStateLabel(subject.state)}</span>
|
||||
<h2>{subject.role}</h2>
|
||||
<code title={subject.id}>{subject.id}</code>
|
||||
</div>
|
||||
<dl class="subject-meta">
|
||||
<div><dt>Store revision</dt><dd>{subject.store_revision}</dd></div>
|
||||
<div><dt>Updated</dt><dd><time datetime={subject.updated_at}>{formatDate(subject.updated_at)}</time></dd></div>
|
||||
<div><dt>Worker</dt><dd>{subject.current_worker?.display_name ?? 'None'}</dd></div>
|
||||
<div>
|
||||
<dt>Worker connection</dt>
|
||||
<dd>
|
||||
<strong>{subject.current_worker ? 'Connected' : 'Not connected'}</strong>
|
||||
{#if subject.current_worker}<span>{subject.current_worker.display_name}</span>{/if}
|
||||
</dd>
|
||||
</div>
|
||||
<div><dt>Last changed</dt><dd><time datetime={subject.updated_at}>{formatDate(subject.updated_at)}</time></dd></div>
|
||||
</dl>
|
||||
</a>
|
||||
{/each}
|
||||
@@ -465,8 +474,8 @@
|
||||
|
||||
.subject-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
@@ -480,12 +489,19 @@
|
||||
}
|
||||
|
||||
.subject-meta dd {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
margin-top: var(--space-1);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.subject-meta dd strong {
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.memory-state {
|
||||
padding: var(--space-5) 0;
|
||||
color: var(--text-muted);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
SubjektivSubjectCreateError,
|
||||
updateSubjektivSubjectBehavior,
|
||||
} from '#lib/workspace/memory/api.ts';
|
||||
import type { SubjektivMemoryState } from '#lib/generated/memory-api.ts';
|
||||
import type { SubjektivMemoryState, SubjektivResidentSurfaceAvailability } from '#lib/generated/memory-api.ts';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
@@ -116,6 +116,24 @@
|
||||
function kindLabel(kind: string): string {
|
||||
return kind.replaceAll('_', ' ');
|
||||
}
|
||||
|
||||
function surfaceLabel(availability: SubjektivResidentSurfaceAvailability): string {
|
||||
if (availability === 'ready') return snapshot?.body_md.trim() ? 'Ready to use' : 'Ready, no context';
|
||||
if (availability === 'ungenerated') return 'Not generated';
|
||||
if (availability === 'stale') return 'Needs refresh';
|
||||
return 'Generation failed';
|
||||
}
|
||||
|
||||
function surfaceSummary(availability: SubjektivResidentSurfaceAvailability): string {
|
||||
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 generated Memory context to show.';
|
||||
}
|
||||
|
||||
function subjectStateLabel(state: 'active' | 'retired'): string {
|
||||
return state === 'active' ? 'Available' : 'Retired';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -136,13 +154,50 @@
|
||||
</header>
|
||||
|
||||
{#if subject}
|
||||
<dl class="subject-facts" aria-label="Subject details">
|
||||
<div><dt>Role</dt><dd>{subject.role}</dd></div>
|
||||
<div><dt>State</dt><dd><span class="subject-state is-{subject.state}">{subject.state}</span></dd></div>
|
||||
<div><dt>Store revision</dt><dd>{subject.store_revision}</dd></div>
|
||||
<div><dt>Updated</dt><dd><time datetime={subject.updated_at}>{formatDate(subject.updated_at)}</time></dd></div>
|
||||
<div><dt>Current worker</dt><dd>{subject.current_worker?.display_name ?? 'None'}</dd></div>
|
||||
</dl>
|
||||
<section class="subject-overview" aria-label="Subject status">
|
||||
<div>
|
||||
<span>Subject status</span>
|
||||
<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>{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>
|
||||
{#if data.memories.data}
|
||||
<strong>{data.cursor ? `${memories.length} on this page` : memories.length === 0 ? 'None' : 'Available'}</strong>
|
||||
<p>{memories.length} shown{data.memories.data.has_more ? ' · more available' : ''}</p>
|
||||
{:else}
|
||||
<strong>Unavailable</strong>
|
||||
<p>The current Memory list could not be read.</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<span>Generated Memory context</span>
|
||||
{#if surface}
|
||||
<strong class="availability is-{surface.availability}">{surfaceLabel(surface.availability)}</strong>
|
||||
<p>{surfaceSummary(surface.availability)}</p>
|
||||
{:else}
|
||||
<strong>Unavailable</strong>
|
||||
<p>The resident context status could not be read.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<details class="subject-technical-details">
|
||||
<summary>Technical details</summary>
|
||||
<dl>
|
||||
<div><dt>Subject ID</dt><dd><code>{subject.id}</code></dd></div>
|
||||
<div>
|
||||
<dt>Subject store revision</dt>
|
||||
<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={subject.updated_at}>{formatDate(subject.updated_at)}</time></dd></div>
|
||||
</dl>
|
||||
</details>
|
||||
{:else if data.subject.error}
|
||||
<div class="memory-state is-error" role="alert">
|
||||
<strong>Subject details unavailable.</strong>
|
||||
@@ -189,38 +244,40 @@
|
||||
<section class="surface-section" aria-labelledby="resident-surface-heading">
|
||||
<header class="section-heading">
|
||||
<div>
|
||||
<p class="memory-eyebrow">Resident context</p>
|
||||
<h2 id="resident-surface-heading">Resident surface</h2>
|
||||
<p class="memory-eyebrow">Generated from committed Memory</p>
|
||||
<h2 id="resident-surface-heading">Resident context</h2>
|
||||
</div>
|
||||
{#if surface}
|
||||
<span class="availability is-{surface.availability}">{surface.availability}</span>
|
||||
<span class="availability is-{surface.availability}">{surfaceLabel(surface.availability)}</span>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if surface?.availability === 'ready' && snapshot}
|
||||
<div class="surface-meta">
|
||||
<span>Built from store revision {snapshot.built_from_store_revision}</span>
|
||||
<span>Generated <time datetime={snapshot.created_at}>{formatDate(snapshot.created_at)}</time></span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<time datetime={snapshot.created_at}>{formatDate(snapshot.created_at)}</time>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{snapshot.memory_refs.length} Memory ref{snapshot.memory_refs.length === 1 ? '' : 's'}</span>
|
||||
<span>{snapshot.memory_refs.length} referenced Memory {snapshot.memory_refs.length === 1 ? 'record' : 'records'}</span>
|
||||
</div>
|
||||
{#if snapshot.body_md.trim().length === 0}
|
||||
<div class="memory-state" role="status" data-surface-ready-empty>
|
||||
<strong>Resident surface is ready and empty.</strong>
|
||||
<p>This subject currently has no resident context to display.</p>
|
||||
<strong>Resident context is current but empty.</strong>
|
||||
<p>A generated surface exists, but it contains no context to show. Committed Memories, if any, remain listed below.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<article class="surface-document" aria-label="Resident surface">
|
||||
<article class="surface-document" aria-label="Resident context">
|
||||
<DocumentMarkdown text={snapshot.body_md} />
|
||||
</article>
|
||||
{/if}
|
||||
<details class="surface-details">
|
||||
<summary>Surface provenance</summary>
|
||||
<summary>Surface sources and diagnostics</summary>
|
||||
<dl>
|
||||
<div><dt>Snapshot id</dt><dd><code>{snapshot.snapshot_id}</code></dd></div>
|
||||
<div><dt>Snapshot ID</dt><dd><code>{snapshot.snapshot_id}</code></dd></div>
|
||||
<div>
|
||||
<dt>Memory refs</dt>
|
||||
<dt>Generated from Subject revision</dt>
|
||||
<dd>{snapshot.built_from_store_revision}<small>The Subject change number used for this surface, distinct from each Memory’s revision.</small></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Referenced Memory revisions</dt>
|
||||
<dd>
|
||||
{#if snapshot.memory_refs.length === 0}
|
||||
None
|
||||
@@ -237,26 +294,26 @@
|
||||
</details>
|
||||
{:else if surface?.availability === 'ungenerated'}
|
||||
<div class="memory-state" role="status">
|
||||
<strong>Resident surface has not been generated.</strong>
|
||||
<p>Committed Memories remain available below.</p>
|
||||
<strong>Resident context has not been generated.</strong>
|
||||
<p>Committed Memories, if any, remain available below.</p>
|
||||
</div>
|
||||
{:else if surface?.availability === 'stale'}
|
||||
<div class="memory-state is-warning" role="status">
|
||||
<strong>Resident surface is stale.</strong>
|
||||
<p>The latest committed Memories are not represented by a publishable snapshot.</p>
|
||||
<strong>Resident context needs to be refreshed.</strong>
|
||||
<p>The latest committed Memories are not represented by a current surface, so no generated context is shown.</p>
|
||||
</div>
|
||||
{:else if surface?.availability === 'failed'}
|
||||
<div class="memory-state is-error" role="alert">
|
||||
<strong>Resident surface generation failed.</strong>
|
||||
<p>No failed or partial snapshot is displayed.</p>
|
||||
<strong>Resident context generation failed.</strong>
|
||||
<p>No failed or partial surface is shown. Committed Memories remain available below.</p>
|
||||
</div>
|
||||
{:else if data.surface.error}
|
||||
<div class="memory-state is-error" role="alert">
|
||||
<strong>Resident surface unavailable.</strong>
|
||||
<strong>Resident context status unavailable.</strong>
|
||||
<p>{data.surface.error}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="memory-state" role="status"><p>Resident surface data is unavailable.</p></div>
|
||||
<div class="memory-state" role="status"><p>Resident context status is unavailable.</p></div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -266,14 +323,14 @@
|
||||
<p class="memory-eyebrow">Committed records</p>
|
||||
<h2 id="current-memories-heading">Current Memories</h2>
|
||||
</div>
|
||||
{#if data.memories.data}<span class="memory-count">{memories.length}{data.memories.data.has_more ? '+' : ''}</span>{/if}
|
||||
{#if data.memories.data}<span class="memory-count">{memories.length} shown{data.memories.data.has_more ? ' · more available' : ''}</span>{/if}
|
||||
</header>
|
||||
|
||||
{#if data.memories.data}
|
||||
{#if memories.length === 0}
|
||||
<div class="memory-state" role="status" data-memories-empty>
|
||||
<strong>No committed Memories.</strong>
|
||||
<p>This subject has no current Memory revisions.</p>
|
||||
<strong>{data.cursor ? 'No committed Memories on this page.' : 'No committed Memories yet.'}</strong>
|
||||
<p>{data.cursor ? 'Return to the first page to review earlier records.' : 'This Subject has no current Memory records.'}</p>
|
||||
{#if data.cursor}<p><a href={memoryPageHref()}>Return to the first page</a></p>{/if}
|
||||
</div>
|
||||
{:else}
|
||||
@@ -289,9 +346,9 @@
|
||||
<p>{memory.excerpt || 'No excerpt.'}</p>
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt>Memory id</dt><dd><code title={memory.id}>{memory.id}</code></dd></div>
|
||||
<div><dt>Revision</dt><dd>{memory.revision}</dd></div>
|
||||
<div><dt>Updated</dt><dd><time datetime={memory.updated_at}>{formatDate(memory.updated_at)}</time></dd></div>
|
||||
<div><dt>Memory ID</dt><dd><code title={memory.id}>{memory.id}</code></dd></div>
|
||||
<div><dt>Memory revision</dt><dd>{memory.revision}</dd></div>
|
||||
<div><dt>Last changed</dt><dd><time datetime={memory.updated_at}>{formatDate(memory.updated_at)}</time></dd></div>
|
||||
</dl>
|
||||
</a>
|
||||
{/each}
|
||||
@@ -384,25 +441,79 @@
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
.subject-facts {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
.subject-overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
background: var(--bg-raised);
|
||||
border-radius: var(--radius-soft);
|
||||
}
|
||||
|
||||
.subject-facts > div {
|
||||
display: block;
|
||||
.subject-overview > div {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.subject-facts dt {
|
||||
white-space: normal;
|
||||
.subject-overview span,
|
||||
.subject-overview p,
|
||||
.subject-technical-details summary,
|
||||
.subject-technical-details small,
|
||||
.surface-details small {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
.subject-facts dd {
|
||||
margin-top: var(--space-1);
|
||||
color: var(--text);
|
||||
.subject-overview strong {
|
||||
color: var(--text-strong);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.subject-overview p {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.subject-technical-details {
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.subject-technical-details summary {
|
||||
width: fit-content;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.subject-technical-details dl,
|
||||
.surface-details dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.subject-technical-details dl > div,
|
||||
.surface-details dl > div {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.subject-technical-details dd,
|
||||
.surface-details dd {
|
||||
margin-top: var(--space-1);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.subject-technical-details small,
|
||||
.surface-details small {
|
||||
display: block;
|
||||
margin-top: var(--space-1);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.subject-state,
|
||||
.availability,
|
||||
.memory-pill,
|
||||
@@ -642,7 +753,7 @@
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.subject-facts {
|
||||
.subject-overview {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@@ -659,10 +770,16 @@
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.subject-facts {
|
||||
.subject-overview,
|
||||
.subject-technical-details dl,
|
||||
.surface-details dl {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.subject-overview {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.memory-row {
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
+20
-9
@@ -63,6 +63,11 @@
|
||||
return value.replaceAll('_', ' ');
|
||||
}
|
||||
|
||||
function titleLabel(value: string): string {
|
||||
const normalized = label(value);
|
||||
return normalized[0].toUpperCase() + normalized.slice(1);
|
||||
}
|
||||
|
||||
function rangeLabel(range: [number, number] | null | undefined): string {
|
||||
return range ? `${range[0]}–${range[1]}` : 'None';
|
||||
}
|
||||
@@ -98,8 +103,8 @@
|
||||
<header class="memory-page-header">
|
||||
<div>
|
||||
<div class="pill-row">
|
||||
<span class="memory-pill is-{memory.state}">{memory.state}</span>
|
||||
<span class="memory-kind">{label(memory.kind)}</span>
|
||||
<span class="memory-pill is-{memory.state}">{titleLabel(memory.state)}</span>
|
||||
<span class="memory-kind">{titleLabel(memory.kind)}</span>
|
||||
{#if memory.revision !== memory.current_revision}<span class="historical-pill">Historical revision</span>{/if}
|
||||
</div>
|
||||
<h1 id="memory-detail-heading">{memory.claim}</h1>
|
||||
@@ -109,11 +114,11 @@
|
||||
</header>
|
||||
|
||||
<dl class="memory-facts" aria-label="Memory details">
|
||||
<div><dt>Revision</dt><dd>{memory.revision} of {memory.current_revision}</dd></div>
|
||||
<div><dt>State</dt><dd>{memory.state}</dd></div>
|
||||
<div><dt>Kind</dt><dd>{label(memory.kind)}</dd></div>
|
||||
<div><dt>Memory revision</dt><dd>{memory.revision} of {memory.current_revision}</dd></div>
|
||||
<div><dt>Status</dt><dd>{titleLabel(memory.state)}</dd></div>
|
||||
<div><dt>Type</dt><dd>{titleLabel(memory.kind)}</dd></div>
|
||||
<div><dt>Created</dt><dd><time datetime={memory.created_at}>{formatDate(memory.created_at)}</time></dd></div>
|
||||
<div><dt>Updated</dt><dd><time datetime={memory.updated_at}>{formatDate(memory.updated_at)}</time></dd></div>
|
||||
<div><dt>Last changed</dt><dd><time datetime={memory.updated_at}>{formatDate(memory.updated_at)}</time></dd></div>
|
||||
</dl>
|
||||
|
||||
<section class="detail-section memory-body-section" aria-labelledby="memory-body-heading">
|
||||
@@ -140,7 +145,7 @@
|
||||
<section class="detail-section provenance-section" aria-labelledby="provenance-heading">
|
||||
<header class="section-heading">
|
||||
<div><p class="memory-eyebrow">Audit context</p><h2 id="provenance-heading">Candidate provenance</h2></div>
|
||||
<span>{memory.source_candidates.length} candidate{memory.source_candidates.length === 1 ? '' : 's'}</span>
|
||||
<span>{memory.source_candidates.length} shown</span>
|
||||
</header>
|
||||
|
||||
{#if memory.source_candidates.length === 0}
|
||||
@@ -226,7 +231,11 @@
|
||||
</section>
|
||||
|
||||
<section class="detail-section revision-section" aria-labelledby="revision-heading">
|
||||
<header class="section-heading"><div><p class="memory-eyebrow">Immutable record</p><h2 id="revision-heading">Revision history</h2></div>{#if data.revisions.data}<span>{revisions.length}{data.revisions.data.has_more ? '+' : ''}</span>{/if}</header>
|
||||
<header class="section-heading">
|
||||
<div><p class="memory-eyebrow">Immutable record</p><h2 id="revision-heading">Revision history</h2></div>
|
||||
{#if data.revisions.data}<span>{revisions.length} shown{data.revisions.data.has_more ? ' · more available' : ''}</span>{/if}
|
||||
</header>
|
||||
<p class="section-intro">These are versions of this Memory record, separate from the Subject’s internal update number.</p>
|
||||
{#if data.revisions.data}
|
||||
{#if revisions.length === 0}
|
||||
<div>
|
||||
@@ -238,7 +247,7 @@
|
||||
{#each revisions as revision (revision.revision)}
|
||||
<li class:current={revision.revision === memory.revision}>
|
||||
<a href={revisionHref(revision.revision)} aria-current={revision.revision === memory.revision ? 'page' : undefined}>
|
||||
<div><strong>Revision {revision.revision}</strong><span class="memory-pill is-{revision.state}">{revision.state}</span><span class="memory-kind">{label(revision.kind)}</span></div>
|
||||
<div><strong>Memory revision {revision.revision}</strong><span class="memory-pill is-{revision.state}">{titleLabel(revision.state)}</span><span class="memory-kind">{titleLabel(revision.kind)}</span></div>
|
||||
<p>{revision.claim}</p>
|
||||
<small>{revision.change_reason} · <time datetime={revision.updated_at}>{formatDate(revision.updated_at)}</time></small>
|
||||
</a>
|
||||
@@ -386,6 +395,7 @@
|
||||
}
|
||||
|
||||
.memory-facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
@@ -414,6 +424,7 @@
|
||||
.why-useful,
|
||||
.staleness,
|
||||
.change-reason,
|
||||
.section-intro,
|
||||
.empty-copy,
|
||||
.bounded-note {
|
||||
margin: 0;
|
||||
|
||||
@@ -75,6 +75,9 @@ test("renders the subject index as the Memory product entry", () => {
|
||||
expect(screen.getByRole("link", { name: /Release coordinator/ })).not
|
||||
.toBeNull();
|
||||
expect(screen.getByText("subject-1")).not.toBeNull();
|
||||
expect(screen.getByText("Not connected")).not.toBeNull();
|
||||
expect(screen.queryByText("Store revision", { exact: true })).toBeNull();
|
||||
expect(screen.getByText("1 shown · more available")).not.toBeNull();
|
||||
expect(screen.getByRole("link", { name: "First page" }).getAttribute("href"))
|
||||
.toBe("/w/workspace-1/memory");
|
||||
expect(screen.getByRole("link", { name: /Next page/ }).getAttribute("href"))
|
||||
@@ -240,7 +243,10 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
|
||||
workspaceId: "workspace-1",
|
||||
subjectId: "subject-1",
|
||||
cursor: "memory-current",
|
||||
subject: result(subject()),
|
||||
subject: result({
|
||||
...subject(),
|
||||
current_worker: { display_name: "Release Worker" },
|
||||
}),
|
||||
surface: result({
|
||||
subject_id: "subject-1",
|
||||
availability: "ready" as const,
|
||||
@@ -298,9 +304,15 @@ test("renders a ready resident surface and committed Memory lifecycle states", a
|
||||
.textContent,
|
||||
)
|
||||
.toContain("Prefer explicit evidence.");
|
||||
expect(screen.getByRole("article", { name: "Resident surface" })).not
|
||||
expect(screen.getByRole("article", { name: "Resident context" })).not
|
||||
.toBeNull();
|
||||
expect(screen.getAllByText("active", { exact: true }).length).toBeGreaterThan(
|
||||
expect(screen.getByText("Connected", { exact: true })).not.toBeNull();
|
||||
expect(screen.getByText("Release Worker", { exact: true })).not.toBeNull();
|
||||
expect(screen.getByText("3 on this page", { exact: true })).not.toBeNull();
|
||||
expect(screen.getByText("Subject store revision", { exact: true })).not
|
||||
.toBeNull();
|
||||
expect(screen.getByText(/not a Memory count/)).not.toBeNull();
|
||||
expect(screen.getAllByText("Active", { exact: true }).length).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
expect(screen.getByText("Resolved", { exact: true })).not.toBeNull();
|
||||
@@ -577,6 +589,93 @@ test("a refreshed loader snapshot fences old saves even after returning to the s
|
||||
).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
|
||||
// the same empty committed list as a new Subject; candidates are not a page input.
|
||||
render(SubjectPage, {
|
||||
data: {
|
||||
workspaceId: "workspace-1",
|
||||
subjectId: "subject-1",
|
||||
cursor: null,
|
||||
subject: result({ ...subject(), store_revision: 0 }),
|
||||
surface: result({ subject_id: "subject-1", availability: "ungenerated" }),
|
||||
memories: result({ limit: 100, items: [], has_more: false }),
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(screen.getByText("None", { exact: true })).not.toBeNull();
|
||||
expect(screen.getByText("No committed Memories yet.")).not.toBeNull();
|
||||
expect(screen.getByText("Not connected", { exact: true })).not.toBeNull();
|
||||
expect(screen.getAllByText("Not generated", { exact: true })).toHaveLength(2);
|
||||
expect(screen.queryByRole("article", { name: "Resident context" }))
|
||||
.toBeNull();
|
||||
expect(
|
||||
screen.queryByText(
|
||||
/candidate count|all Memories lost|automatically updating/i,
|
||||
),
|
||||
).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",
|
||||
@@ -600,8 +699,25 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s
|
||||
}),
|
||||
},
|
||||
} as never);
|
||||
expect(screen.getByText("Resident surface is ready and empty.")).not
|
||||
expect(screen.getByText("Resident context is current but empty.")).not
|
||||
.toBeNull();
|
||||
expect(screen.getByText(/current empty surface exists/)).not.toBeNull();
|
||||
expect(screen.getByText("No committed Memories yet.")).not.toBeNull();
|
||||
|
||||
await view.rerender(
|
||||
{
|
||||
data: {
|
||||
...base,
|
||||
surface: result({
|
||||
subject_id: "subject-1",
|
||||
availability: "ungenerated" as const,
|
||||
}),
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
expect(screen.getByText("Resident context has not been generated.")).not
|
||||
.toBeNull();
|
||||
expect(screen.getAllByText("Not generated", { exact: true })).toHaveLength(2);
|
||||
|
||||
await view.rerender(
|
||||
{
|
||||
@@ -614,7 +730,8 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
expect(screen.getByText("Resident surface is stale.")).not.toBeNull();
|
||||
expect(screen.getByText("Resident context needs to be refreshed.")).not
|
||||
.toBeNull();
|
||||
|
||||
await view.rerender(
|
||||
{
|
||||
@@ -630,7 +747,7 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s
|
||||
expect(screen.getByRole("alert").textContent).toContain("generation failed");
|
||||
|
||||
await view.rerender({ data: { ...base, surface: result(null) } } as never);
|
||||
expect(screen.getByText("Resident surface data is unavailable.")).not
|
||||
expect(screen.getByText("Resident context status is unavailable.")).not
|
||||
.toBeNull();
|
||||
|
||||
await view.rerender(
|
||||
@@ -639,7 +756,7 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s
|
||||
} as never,
|
||||
);
|
||||
expect(screen.getByRole("alert").textContent).toContain(
|
||||
"Resident surface unavailable",
|
||||
"Resident context status unavailable",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1663,45 +1663,6 @@
|
||||
</svelte:head>
|
||||
|
||||
<div class="console-shell worker-console-shell">
|
||||
<section class="console-header card" aria-label="Worker controls">
|
||||
<WorkerContextStatus metadata={selectedConsoleProjection.workerMetadata} />
|
||||
<div class="console-header-actions">
|
||||
<div
|
||||
class="console-view-modes"
|
||||
role="group"
|
||||
aria-label="Console display mode"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class:active={consoleViewMode === "overview"}
|
||||
aria-pressed={consoleViewMode === "overview"}
|
||||
onclick={() => (consoleViewMode = "overview")}
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class:active={consoleViewMode === "normal"}
|
||||
aria-pressed={consoleViewMode === "normal"}
|
||||
onclick={() => (consoleViewMode = "normal")}
|
||||
>
|
||||
Normal
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-button"
|
||||
aria-expanded={workerDetailsOpen}
|
||||
onclick={() => {
|
||||
workerDetailsOpen = !workerDetailsOpen;
|
||||
if (workerDetailsOpen) taskPaneOpen = false;
|
||||
}}
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if rewindTargets.length > 0}
|
||||
<section class="card rewind-targets" aria-label="Rewind targets">
|
||||
<h3>Rewind targets</h3>
|
||||
@@ -2106,6 +2067,28 @@
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<WorkerContextStatus
|
||||
metadata={selectedConsoleProjection.workerMetadata}
|
||||
detailsOpen={workerDetailsOpen}
|
||||
onToggleDetails={() => {
|
||||
workerDetailsOpen = !workerDetailsOpen;
|
||||
if (workerDetailsOpen) taskPaneOpen = false;
|
||||
}}
|
||||
>
|
||||
{#snippet controls()}
|
||||
<button
|
||||
type="button"
|
||||
class="console-view-toggle"
|
||||
role="switch"
|
||||
aria-checked={consoleViewMode === "overview"}
|
||||
title="On: Overview · Off: Normal"
|
||||
onclick={() => (consoleViewMode = consoleViewMode === "overview" ? "normal" : "overview")}
|
||||
>
|
||||
<span class="console-view-toggle-track" aria-hidden="true"></span>
|
||||
<span>Overview</span>
|
||||
</button>
|
||||
{/snippet}
|
||||
</WorkerContextStatus>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -2131,14 +2114,6 @@
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.console-header {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.console-card {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
@@ -2146,46 +2121,59 @@
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.console-header-actions {
|
||||
display: flex;
|
||||
.console-view-toggle {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.console-view-modes {
|
||||
display: inline-flex;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.55rem;
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
|
||||
.console-view-modes button {
|
||||
gap: var(--space-1);
|
||||
min-height: var(--space-5);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--space-1);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font: inherit;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--font-size-compact);
|
||||
line-height: var(--line-height-compact);
|
||||
font-weight: 700;
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.console-view-modes button + button {
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.console-view-modes button:hover {
|
||||
.console-view-toggle:hover {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.console-view-modes button.active {
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
.console-view-toggle:focus-visible {
|
||||
outline: 1px solid var(--text-strong);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.console-view-toggle-track {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
width: 24px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
background: var(--line-strong);
|
||||
}
|
||||
|
||||
.console-view-toggle-track::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.console-view-toggle[aria-checked="true"] .console-view-toggle-track {
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
|
||||
.console-view-toggle[aria-checked="true"] .console-view-toggle-track::before {
|
||||
transform: translateX(10px);
|
||||
}
|
||||
|
||||
.rewind-targets {
|
||||
@@ -2684,14 +2672,5 @@
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.console-header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.console-header-actions {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+18
-3
@@ -261,13 +261,28 @@ test.each(["live_protocol", "retained_snapshot"] as const)("overlapping history
|
||||
await screen.findByText("Start of conversation");
|
||||
expect(view.container.querySelectorAll(".activity-summary")).toHaveLength(1);
|
||||
expect(screen.getAllByText("searched 1 time・ran 1 command")).toHaveLength(1);
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Normal" }));
|
||||
const footer = screen.getByLabelText("Worker model and context");
|
||||
const overview = screen.getByRole("switch", { name: "Overview" });
|
||||
expect(screen.queryByRole("button", { name: "Normal" })).toBeNull();
|
||||
const details = screen.getByRole("button", { name: "Details" });
|
||||
expect(view.container.querySelector(".console-header")).toBeNull();
|
||||
expect(footer.contains(overview)).toBe(true);
|
||||
expect(overview.nextElementSibling).toBe(details);
|
||||
expect(overview.closest("form")).toBeNull();
|
||||
expect(view.container.querySelector(".console-composer")?.nextElementSibling).toBe(footer);
|
||||
expect(overview.getAttribute("aria-checked")).toBe("true");
|
||||
await fireEvent.click(overview);
|
||||
expect(overview.getAttribute("aria-checked")).toBe("false");
|
||||
expect(overview.textContent?.trim()).toBe("Overview");
|
||||
const transcript = screen.getByRole("article", { name: "main transcript" });
|
||||
const ids = [...transcript.querySelectorAll("[data-console-line-id]")].map((line) => line.getAttribute("data-console-line-id"));
|
||||
expect(ids).toHaveLength(3);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Overview" }));
|
||||
await fireEvent.click(overview);
|
||||
expect(overview.getAttribute("aria-checked")).toBe("true");
|
||||
expect(overview.textContent?.trim()).toBe("Overview");
|
||||
expect(view.container.querySelectorAll(".activity-summary")).toHaveLength(1);
|
||||
expect(multiplexer.sendWorkerMethod).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each([
|
||||
@@ -473,7 +488,7 @@ test("mini task summary opens and closes details without a header Tasks button",
|
||||
latestListener().onFrame(subscribedFrame(session));
|
||||
const summary = await screen.findByRole("button", { name: /1 task — pending/ });
|
||||
expect(screen.queryByRole("complementary", { name: "Worker tasks" })).toBeNull();
|
||||
expect(view.container.querySelector(".console-header")?.textContent).not.toContain("Tasks");
|
||||
expect(view.container.querySelector(".console-header")).toBeNull();
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Details" }));
|
||||
const details = await screen.findByRole("complementary", { name: "Worker detail" });
|
||||
expect(details.textContent).not.toContain("Capabilities");
|
||||
|
||||
@@ -71,18 +71,105 @@ Deno.test("config source API commits directly through the workspace scope", asyn
|
||||
entrypoints: [],
|
||||
}, fetcher);
|
||||
|
||||
assertEquals(calls.map((call) => call.url), [
|
||||
assertEquals<unknown>(calls.map((call) => call.url), [
|
||||
"/api/w/w%2Fone/config/source-tree",
|
||||
"/api/w/w%2Fone/config/source-tree/revisions/7",
|
||||
"/api/w/w%2Fone/config/source-tree/entries/profiles%2Fmain.dcdl",
|
||||
"/api/w/w%2Fone/config/source-tree/commit",
|
||||
]);
|
||||
assertEquals(calls[3].init?.method, "POST");
|
||||
assertEquals<unknown>(calls[3].init?.method, "POST");
|
||||
assert(
|
||||
String(calls[3].init?.body).includes('"base_digest":"sha256:base"'),
|
||||
);
|
||||
});
|
||||
|
||||
function withSchema(source: unknown, contributionSource: unknown = "builtin") {
|
||||
return {
|
||||
...tree,
|
||||
contract: {
|
||||
...tree.contract,
|
||||
schema_bundle: {
|
||||
...tree.contract.schema_bundle,
|
||||
source,
|
||||
contributions: [{
|
||||
provider_id: "builtin",
|
||||
namespace: "workspace",
|
||||
version: "1",
|
||||
source: contributionSource,
|
||||
source_digest: "sha256:contribution",
|
||||
}],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
for (const field of ["bundle", "contribution"] as const) {
|
||||
Deno.test(`config source API accepts ${field} schema bodies larger than metadata`, async () => {
|
||||
const source = "schema body\n".repeat(6000);
|
||||
const body = field === "bundle"
|
||||
? withSchema(source)
|
||||
: withSchema("builtin", source);
|
||||
const fetcher = (() => Promise.resolve(response(body))) as typeof fetch;
|
||||
assertEquals<unknown>(await fetchConfigTree("w", fetcher), body);
|
||||
assertEquals<unknown>(
|
||||
await commitConfigTree("w", {
|
||||
base_revision: 7,
|
||||
base_digest: snapshot.digest,
|
||||
changes: [],
|
||||
entrypoints: ["main.dcdl"],
|
||||
}, fetcher),
|
||||
body,
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test(`config source API bounds ${field} schema bodies in UTF-8 bytes and checks types`, () => {
|
||||
const body = (source: unknown) =>
|
||||
field === "bundle" ? withSchema(source) : withSchema("builtin", source);
|
||||
const limit = 8 * 1024 * 1024;
|
||||
const atLimit = "é".repeat(limit / 2);
|
||||
assertEquals<unknown>(
|
||||
parseWorkspaceConfigTreeResponse(body(atLimit)),
|
||||
body(atLimit),
|
||||
);
|
||||
for (const invalid of [atLimit + "x", null, 42, {}]) {
|
||||
assertThrows(
|
||||
() => parseWorkspaceConfigTreeResponse(body(invalid)),
|
||||
ConfigSourceApiError,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Deno.test("config source API retains metadata and total response limits", async () => {
|
||||
const invalid = withSchema("builtin");
|
||||
invalid.contract.schema_bundle.contributions[0].provider_id = "x".repeat(
|
||||
4097,
|
||||
);
|
||||
assertThrows(
|
||||
() => parseWorkspaceConfigTreeResponse(invalid),
|
||||
ConfigSourceApiError,
|
||||
);
|
||||
for (
|
||||
const headers of [
|
||||
new Headers(),
|
||||
new Headers({ "content-length": String(8 * 1024 * 1024 + 1) }),
|
||||
]
|
||||
) {
|
||||
const fetcher = (() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify(withSchema("x".repeat(8 * 1024 * 1024))),
|
||||
{ headers },
|
||||
),
|
||||
)) as typeof fetch;
|
||||
await assertRejects(
|
||||
() => fetchConfigTree("w", fetcher),
|
||||
ConfigSourceApiError,
|
||||
"too large",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("config source API rejects unknown response fields", async () => {
|
||||
const fetcher =
|
||||
(() =>
|
||||
|
||||
@@ -62,9 +62,7 @@ async function assertSubjectCreateRejects(
|
||||
assertEquals(error.status, expectedStatus);
|
||||
if (!error.message.includes(expectedMessage)) {
|
||||
throw new Error(
|
||||
`expected ${JSON.stringify(error.message)} to include ${
|
||||
JSON.stringify(expectedMessage)
|
||||
}`,
|
||||
`expected ${JSON.stringify(error.message)} to include ${JSON.stringify(expectedMessage)}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -85,6 +83,30 @@ function subject(id = "subject-1") {
|
||||
};
|
||||
}
|
||||
|
||||
function currentWorker() {
|
||||
return {
|
||||
runtime_id: "runtime-1",
|
||||
worker_id: "worker-1",
|
||||
host_id: "host-1",
|
||||
display_name: "Release Worker",
|
||||
label: "Release Worker",
|
||||
profile: "builtin:companion",
|
||||
singleton_key: "subjektiv:subject-1",
|
||||
tags: [],
|
||||
workspace: {
|
||||
visibility: "workspace",
|
||||
identity: "workspace-1",
|
||||
workspace_id: "workspace-1",
|
||||
},
|
||||
state: "idle",
|
||||
last_seen_at: "2026-09-02T00:00:00Z",
|
||||
pinned: false,
|
||||
retention_state: "retained",
|
||||
implementation: { kind: "embedded", display_hint: "Embedded" },
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
function queryItem(id = "memory-1") {
|
||||
return {
|
||||
id,
|
||||
@@ -190,14 +212,35 @@ Deno.test("Subject parsers enforce identity, exact enums, safe revisions, and bo
|
||||
() =>
|
||||
parseSubjektivSubjectListResponse({
|
||||
limit: 100,
|
||||
items: Array.from({ length: 101 }, (_, index) =>
|
||||
subject(`subject-${index}`)),
|
||||
items: Array.from({ length: 101 }, (_, index) => subject(`subject-${index}`)),
|
||||
has_more: false,
|
||||
}),
|
||||
"bounded array",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Subject parser preserves live Worker connection state", () => {
|
||||
assertEquals(
|
||||
parseSubjektivSubjectResponse({
|
||||
...subject(),
|
||||
current_worker: currentWorker(),
|
||||
}),
|
||||
{ ...subject(), current_worker: currentWorker() },
|
||||
);
|
||||
assertEquals(
|
||||
parseSubjektivSubjectResponse({ ...subject(), current_worker: null }),
|
||||
{ ...subject(), current_worker: null },
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivSubjectResponse({
|
||||
...subject(),
|
||||
current_worker: { display_name: "Incomplete Worker" },
|
||||
}),
|
||||
"current worker.runtime_id",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Subject list parser preserves the exact bounded response", () => {
|
||||
const response = { limit: 100, items: [subject()], has_more: false };
|
||||
assertEquals(parseSubjektivSubjectListResponse(response), response);
|
||||
@@ -401,8 +444,7 @@ Deno.test("Subject create client distinguishes rejected and unknown outcomes wit
|
||||
throw new TypeError("connection reset");
|
||||
}) as typeof fetch;
|
||||
await assertSubjectCreateRejects(
|
||||
() =>
|
||||
createSubjektivSubject(unknownFetch, "workspace-1", { role: "Reviewer" }),
|
||||
() => createSubjektivSubject(unknownFetch, "workspace-1", { role: "Reviewer" }),
|
||||
"unknown_outcome",
|
||||
"may have been created",
|
||||
);
|
||||
@@ -506,8 +548,7 @@ Deno.test("Current Memory list parser rejects unknown variants, unsafe integers,
|
||||
assertThrows(
|
||||
() =>
|
||||
parseSubjektivMemoryQueryResponse({
|
||||
items: Array.from({ length: 101 }, (_, index) =>
|
||||
queryItem(`memory-${index}`)),
|
||||
items: Array.from({ length: 101 }, (_, index) => queryItem(`memory-${index}`)),
|
||||
has_more: false,
|
||||
}),
|
||||
"bounded array",
|
||||
|
||||
Reference in New Issue
Block a user