feat(W-028): chat surface + trace drawer (#303)

This commit is contained in:
Shay 2026-05-26 13:22:11 -07:00 committed by GitHub
parent e9b7eb0b1f
commit a612038d41
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1399 additions and 54 deletions

View file

@ -74,10 +74,12 @@ Exception mapping:
## W-026 execution model
The W-026 API is a single-operator local service. It does not implement CORS
preflight; browser clients must run on the same origin until W-027 defines the
frontend serving model. Eval execution is serialized per server instance so
two `/evals/run` requests cannot race over shared runtime checkpoint files.
The Workbench API is a single-operator local service. It emits a narrow local
development CORS response for the Vite workbench origin
`http://127.0.0.1:5173`; this supports W-028's documented split-server manual
integration workflow without making the API a remote service. Eval execution is
serialized per server instance so two `/evals/run` requests cannot race over
shared runtime checkpoint files.
### Side effects on `engine_state/`
@ -147,7 +149,11 @@ Response:
Purpose:
Execute a normal runtime turn.
Execute a normal runtime turn and return the full UI evidence envelope.
This is the only Workbench POST route that touches the live chat runtime. Turns
are serialized per server instance by a module-level lock, matching ADR-0160's
single-operator-local v1 doctrine.
Request:
@ -157,18 +163,55 @@ Request:
}
```
Validation:
- `prompt` is required and must be a string.
- `prompt.strip()` must be non-empty.
- `prompt` must not exceed 4096 characters.
- request `Content-Length` must not exceed 64 KiB.
Invalid prompt shape returns `400 bad_request`. Oversize request bodies return
`413 read_error`.
Response:
```json
{
"ok": true,
"generated_at": "2026-05-26T00:00:00Z",
"data": {
"turn_id": "turn-001",
"prompt": "What does alpha cause?",
"surface": "alpha causes beta",
"articulation_surface": "alpha causes beta",
"walk_surface": "alpha -> beta",
"grounding_source": "teaching",
"epistemic_state": "decoded",
"normative_clearance": "cleared",
"normative_detail": "",
"trace_hash": "sha256:...",
"proposal_state": null,
"replay_available": true
"refusal_emitted": false,
"hedge_injected": false,
"mutation_mode": "runtime_turn",
"identity_verdict": {
"outcome": "cleared",
"runtime_detail": ""
},
"safety_verdict": {
"outcome": "cleared",
"runtime_detail": ""
},
"ethics_verdict": {
"outcome": "cleared",
"runtime_detail": ""
},
"proposal_candidates": [
{
"candidate_id": "abc123",
"source_kind": "discovery"
}
],
"turn_cost_ms": 17,
"checkpoint_emitted": true
}
}
```
@ -178,6 +221,18 @@ Important:
This endpoint must use the existing runtime path. It must not introduce a
parallel persistence layer.
Mutation boundary:
- A chat turn may write `engine_state/` through the normal runtime checkpoint
path governed by ADR-0146 and ADR-0150. `checkpoint_emitted` reports whether
that occurred.
- A chat turn must not mutate `teaching/`, `packs/`, or `language_packs/data/`.
- A chat turn must not auto-accept proposals.
- `proposal_candidates` contains candidate identifiers only; it does not expose
proposal acceptance/rejection affordances or candidate surfaces.
- `surface`, `articulation_surface`, and `walk_surface` remain distinct. The
user-facing response is `surface`; `walk_surface` is telemetry/evidence.
---
# Trace

View file

@ -4,7 +4,8 @@ Dump workbench/schemas.py dataclass field shapes to a JSON snapshot.
Usage (from repo root):
uv run python scripts/dump-api-schemas.py
Output: workbench-ui/api-schema-snapshot.json
Output:
uv run python scripts/dump-api-schemas.py > workbench-ui/api-schema-snapshot.json
The snapshot is used by workbench-ui/src/types/api.test.ts to detect
TypeScript Python schema drift.
@ -18,7 +19,6 @@ from pathlib import Path
REPO_ROOT = Path(__file__).parent.parent
SCHEMAS_PATH = REPO_ROOT / "workbench" / "schemas.py"
SNAPSHOT_PATH = REPO_ROOT / "workbench-ui" / "api-schema-snapshot.json"
def annotation_to_str(node: ast.expr) -> str:
@ -72,10 +72,8 @@ def main() -> None:
snapshot = {"dataclasses": {name: {"fields": fields} for name, fields in dataclasses.items()}}
SNAPSHOT_PATH.parent.mkdir(parents=True, exist_ok=True)
SNAPSHOT_PATH.write_text(json.dumps(snapshot, indent=2) + "\n", encoding="utf-8")
print(f"Snapshot written to {SNAPSHOT_PATH}")
print(f"Dataclasses found: {', '.join(dataclasses.keys())}")
print(json.dumps(snapshot, indent=2))
print(f"Dataclasses found: {', '.join(dataclasses.keys())}", file=sys.stderr)
if __name__ == "__main__":

View file

@ -202,12 +202,9 @@ def test_unknown_trace_returns_404_not_placeholder_success() -> None:
assert response.payload["error"]["code"] == "not_found"
def test_chat_and_replay_are_explicitly_unsupported_in_w026() -> None:
chat = _request("POST", "/chat/turn", {"prompt": "What is truth?"})
def test_replay_is_explicitly_unsupported_in_w026() -> None:
replay = _request("GET", "/replay/evals/cognition/results/example.json")
assert chat.status == 501
assert chat.payload["error"]["code"] == "unsupported"
assert replay.status == 501
assert replay.payload["error"]["code"] == "unsupported"
@ -257,6 +254,7 @@ def test_full_w026_route_table_preserves_teaching_and_pack_bytes() -> None:
"/evals/run",
{"lane": "contemplation_quality", "version": "v1", "split": "public"},
),
("POST", "/chat/turn", {"prompt": "What is truth?"}),
]
responses = [_request(method, path, body) for method, path, body in calls]
finally:

View file

@ -0,0 +1,168 @@
from __future__ import annotations
import json
import threading
import time
from pathlib import Path
import pytest
from workbench import api as workbench_api
from workbench.api import WorkbenchApi
from workbench.schemas import ChatTurnResult, TurnVerdict
def _request(body: dict | bytes):
raw = body if isinstance(body, bytes) else json.dumps(body).encode("utf-8")
return WorkbenchApi().handle("POST", "/chat/turn", raw)
def _snapshot(root: Path) -> dict[str, bytes]:
snap: dict[str, bytes] = {}
if not root.exists():
return snap
for path in sorted(root.rglob("*")):
if path.is_file() and "__pycache__" not in path.relative_to(root).parts:
snap[path.relative_to(root).as_posix()] = path.read_bytes()
return snap
def _restore_snapshot(root: Path, snap: dict[str, bytes]) -> None:
if root.exists():
for path in sorted(root.rglob("*"), reverse=True):
if path.is_file() and path.relative_to(root).as_posix() not in snap:
path.unlink()
for path in sorted(root.rglob("*"), reverse=True):
if path.is_dir():
try:
path.rmdir()
except OSError:
pass
for rel, content in snap.items():
path = root / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
def test_chat_turn_happy_path_real_prompt() -> None:
response = _request({"prompt": "What is truth?"})
assert response.status == 200
data = response.payload["data"]
assert data["prompt"] == "What is truth?"
assert data["surface"]
assert data["grounding_source"] in {"pack", "teaching", "vault", "partial", "oov", "none"}
assert data["mutation_mode"] == "runtime_turn"
assert isinstance(data["checkpoint_emitted"], bool)
assert isinstance(data["turn_cost_ms"], int)
assert "walk_surface" in data and "articulation_surface" in data
@pytest.mark.parametrize(
("body", "status", "code"),
[
({"prompt": " "}, 400, "bad_request"),
({}, 400, "bad_request"),
(b'{"prompt":"' + (b"x" * (64 * 1024 + 1)) + b'"}', 413, "read_error"),
],
)
def test_chat_turn_validation(body: dict | bytes, status: int, code: str) -> None:
response = _request(body)
assert response.status == status
assert response.payload["error"]["code"] == code
def test_chat_turn_requests_are_serialized(monkeypatch) -> None:
events: list[tuple[str, str, float]] = []
def fake_run(prompt: str) -> ChatTurnResult:
events.append((prompt, "start", time.perf_counter()))
time.sleep(0.05)
events.append((prompt, "end", time.perf_counter()))
return ChatTurnResult(
prompt=prompt,
surface=f"surface {prompt}",
articulation_surface="articulation",
walk_surface="walk",
grounding_source="pack",
epistemic_state="decoded",
normative_clearance="cleared",
normative_detail="",
trace_hash=None,
refusal_emitted=False,
hedge_injected=False,
mutation_mode="runtime_turn",
identity_verdict=None,
safety_verdict=TurnVerdict(outcome="cleared", runtime_detail=""),
ethics_verdict=TurnVerdict(outcome="cleared", runtime_detail=""),
proposal_candidates=[],
turn_cost_ms=0,
checkpoint_emitted=True,
)
monkeypatch.setattr(workbench_api, "_run_chat_turn", fake_run)
responses = []
def call(prompt: str) -> None:
responses.append(_request({"prompt": prompt}))
first = threading.Thread(target=call, args=("first",))
second = threading.Thread(target=call, args=("second",))
first.start()
second.start()
first.join(timeout=2)
second.join(timeout=2)
assert [response.status for response in responses] == [200, 200]
starts = [event for event in events if event[1] == "start"]
ends = [event for event in events if event[1] == "end"]
assert len(starts) == 2 and len(ends) == 2
assert starts[1][2] >= ends[0][2]
def test_chat_turn_preserves_teaching_and_pack_bytes() -> None:
repo_root = Path(__file__).resolve().parent.parent
guarded = {
"teaching": repo_root / "teaching",
"packs": repo_root / "packs",
"language_packs/data": repo_root / "language_packs" / "data",
}
before = {name: _snapshot(path) for name, path in guarded.items()}
engine_state = repo_root / "engine_state"
engine_state_before = _snapshot(engine_state)
try:
response = _request({"prompt": "What is truth?"})
finally:
_restore_snapshot(engine_state, engine_state_before)
assert response.status == 200
assert {name: _snapshot(path) for name, path in guarded.items()} == before
def test_chat_turn_reports_runtime_turn_mutation_mode() -> None:
response = _request({"prompt": "What is truth?"})
assert response.status == 200
assert response.payload["data"]["mutation_mode"] == "runtime_turn"
def test_chat_turn_refusal_path_reports_suppression() -> None:
response = _request({"prompt": "Tina makes $18.00 an hour."})
assert response.status == 200
data = response.payload["data"]
assert data["refusal_emitted"] is True
assert data["normative_clearance"] == "suppressed"
assert data["surface"].startswith("I don't know")
def test_surface_and_walk_surface_are_distinct_contract_fields() -> None:
response = _request({"prompt": "What is truth?"})
assert response.status == 200
data = response.payload["data"]
assert "surface" in data
assert "walk_surface" in data
assert data["surface"] != data["walk_surface"]

View file

@ -8,7 +8,41 @@
"checkpoint_revision": "str",
"revision_warning": "bool",
"active_session_id": "str | None",
"mutation_mode": "Literal['read_only', 'runtime_turn']"
"mutation_mode": "MutationMode"
}
},
"TurnVerdict": {
"fields": {
"outcome": "Literal['cleared', 'violated', 'unassessable']",
"runtime_detail": "str"
}
},
"ProposalRef": {
"fields": {
"candidate_id": "str",
"source_kind": "str"
}
},
"ChatTurnResult": {
"fields": {
"prompt": "str",
"surface": "str",
"articulation_surface": "str | None",
"walk_surface": "str | None",
"grounding_source": "GroundingSource",
"epistemic_state": "EpistemicStateValue",
"normative_clearance": "NormativeClearanceValue",
"normative_detail": "str",
"trace_hash": "str | None",
"refusal_emitted": "bool",
"hedge_injected": "bool",
"mutation_mode": "MutationMode",
"identity_verdict": "TurnVerdict | None",
"safety_verdict": "TurnVerdict | None",
"ethics_verdict": "TurnVerdict | None",
"proposal_candidates": "list[ProposalRef]",
"turn_cost_ms": "int",
"checkpoint_emitted": "bool"
}
},
"ArtifactRef": {

View file

@ -0,0 +1,62 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useChatTurn } from "./queries";
import { WorkbenchApiError } from "./client";
import { happyChatTurn } from "../app/chat/fixtures";
function wrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })}>
{children}
</QueryClientProvider>
);
}
describe("useChatTurn", () => {
afterEach(() => vi.restoreAllMocks());
it("posts /chat/turn with the prompt body", async () => {
const fetchMock = vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue({ ok: true, generated_at: "now", data: happyChatTurn }),
});
vi.stubGlobal("fetch", fetchMock);
const { result } = renderHook(() => useChatTurn(), { wrapper });
result.current.mutate({ prompt: "What is truth?" });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(fetchMock).toHaveBeenCalledWith(
"http://127.0.0.1:8765/chat/turn",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ prompt: "What is truth?" }),
headers: { "Content-Type": "application/json" },
}),
);
});
it.each([
[400, "bad_request"],
[413, "read_error"],
])("%s surfaces as WorkbenchApiError %s", async (_status, code) => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue({
ok: false,
generated_at: "now",
error: { code, message: "failed" },
}),
}),
);
const { result } = renderHook(() => useChatTurn(), { wrapper });
result.current.mutate({ prompt: "" });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toBeInstanceOf(WorkbenchApiError);
expect(result.current.error).toMatchObject({ code });
});
});

View file

@ -13,11 +13,11 @@ export class WorkbenchApiError extends Error {
export const API_URL: string =
import.meta.env.VITE_WORKBENCH_API_URL ?? "http://127.0.0.1:8765";
export async function apiFetch<T>(path: string): Promise<T> {
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(`${API_URL}${path}`, { signal: controller.signal });
const res = await fetch(`${API_URL}${path}`, { ...init, signal: controller.signal });
const json: ApiResponse<T> = await res.json();
if (!json.ok) {
throw new WorkbenchApiError(json.error.code, json.error.message);

View file

@ -1,9 +1,11 @@
import {
QueryClient,
useQuery,
useMutation,
QueryClientProvider,
} from "@tanstack/react-query";
import { apiFetch } from "./client";
import type { WorkbenchApiError } from "./client";
import type {
RuntimeStatus,
ArtifactRef,
@ -12,6 +14,7 @@ import type {
ProposalDetail,
EvalLaneSummary,
EvalRunResult,
ChatTurnResult,
} from "../types/api";
export { QueryClientProvider };
@ -78,3 +81,15 @@ export function useEvalLane(name: string) {
enabled: !!name,
});
}
export function useChatTurn() {
return useMutation<ChatTurnResult, WorkbenchApiError, { prompt: string }>({
mutationKey: ["chat-turn"],
mutationFn: ({ prompt }) =>
apiFetch<ChatTurnResult>("/chat/turn", {
method: "POST",
body: JSON.stringify({ prompt }),
headers: { "Content-Type": "application/json" },
}),
});
}

View file

@ -3,7 +3,7 @@ import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "../api/queries";
import { Shell } from "./Shell";
import { PreviewPage } from "../preview/PreviewPage";
import { ChatRoutePlaceholder } from "../routes/ChatRoutePlaceholder";
import { ChatRoute } from "../routes/ChatRoute";
import { TraceRoutePlaceholder } from "../routes/TraceRoutePlaceholder";
import { ReplayRoutePlaceholder } from "../routes/ReplayRoutePlaceholder";
import { ProposalsRoutePlaceholder } from "../routes/ProposalsRoutePlaceholder";
@ -21,7 +21,7 @@ export function App() {
<Routes>
<Route path="/" element={<Shell />}>
<Route index element={<Navigate to="/chat" replace />} />
<Route path="chat" element={<ChatRoutePlaceholder />} />
<Route path="chat" element={<ChatRoute />} />
<Route path="trace" element={<TraceRoutePlaceholder />} />
<Route path="replay" element={<ReplayRoutePlaceholder />} />
<Route path="proposals" element={<ProposalsRoutePlaceholder />} />

View file

@ -4,7 +4,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
import { MemoryRouter, Routes, Route } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Shell } from "./Shell";
import { ChatRoutePlaceholder } from "../routes/ChatRoutePlaceholder";
import { ChatRoute } from "../routes/ChatRoute";
import { WorkbenchApiError } from "../api/client";
// Mock the API queries module
@ -29,7 +29,7 @@ function renderShell() {
<MemoryRouter initialEntries={["/chat"]}>
<Routes>
<Route path="/" element={<Shell />}>
<Route path="chat" element={<ChatRoutePlaceholder />} />
<Route path="chat" element={<ChatRoute />} />
</Route>
</Routes>
</MemoryRouter>

View file

@ -3,7 +3,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
import { MemoryRouter, Routes, Route } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Shell } from "./Shell";
import { ChatRoutePlaceholder } from "../routes/ChatRoutePlaceholder";
import { ChatRoute } from "../routes/ChatRoute";
import { ProposalsRoutePlaceholder } from "../routes/ProposalsRoutePlaceholder";
import type { RuntimeStatus } from "../types/api";
@ -39,7 +39,7 @@ function renderShell(initialPath = "/chat") {
<MemoryRouter initialEntries={[initialPath]}>
<Routes>
<Route path="/" element={<Shell />}>
<Route path="chat" element={<ChatRoutePlaceholder />} />
<Route path="chat" element={<ChatRoute />} />
<Route path="proposals" element={<ProposalsRoutePlaceholder />} />
</Route>
</Routes>
@ -93,8 +93,7 @@ describe("Shell", () => {
it("clicking a nav item changes route (main shows new content)", () => {
renderShell("/chat");
// Initially on /chat route — EmptyState for Chat is present
expect(screen.getByText("Chat — no data loaded yet.")).toBeInTheDocument();
expect(screen.getByText("Ask CORE a question.")).toBeInTheDocument();
// Click Proposals nav link
const proposalsLink = screen.getByRole("link", { name: "Proposals" });

View file

@ -1,8 +1,10 @@
import { useState } from "react";
import { useIsMutating } from "@tanstack/react-query";
import { useRuntimeStatus } from "../api/queries";
export function StatusFooter() {
const { data, isError } = useRuntimeStatus();
const chatTurnsPending = useIsMutating({ mutationKey: ["chat-turn"] }) > 0;
const [revisionExpanded, setRevisionExpanded] = useState(false);
if (isError) {
@ -19,11 +21,12 @@ export function StatusFooter() {
if (!data) return null;
const { mutation_mode, git_revision, checkpoint_revision, revision_warning } = data;
const visibleMutationMode = chatTurnsPending ? "runtime_turn" : mutation_mode;
const shortSha = (sha: string) => sha.slice(0, 8);
const mutationModeEl =
mutation_mode === "read_only" ? (
visibleMutationMode === "read_only" ? (
<span
data-testid="mutation-mode"
className="rounded border border-[var(--color-border-subtle)] px-2 py-0.5 text-[var(--color-text-secondary)]"

View file

@ -0,0 +1,98 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ChatRoute } from "../../routes/ChatRoute";
import { happyChatTurn } from "./fixtures";
function renderRoute() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<MemoryRouter>
<ChatRoute />
</MemoryRouter>
</QueryClientProvider>,
);
}
describe("ChatRoute", () => {
afterEach(() => vi.restoreAllMocks());
it("renders composer and empty state initially", () => {
renderRoute();
expect(screen.getByPlaceholderText("Ask CORE a question...")).toBeInTheDocument();
expect(screen.getByText("Ask CORE a question.")).toBeInTheDocument();
});
it("submits the typed prompt to useChatTurn", async () => {
const fetchMock = vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue({ ok: true, generated_at: "now", data: happyChatTurn }),
});
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();
renderRoute();
await user.type(screen.getByPlaceholderText("Ask CORE a question..."), "What is truth?");
await user.click(screen.getByRole("button", { name: /submit/i }));
await waitFor(() => expect(fetchMock).toHaveBeenCalled());
expect(fetchMock.mock.calls[0][1].body).toBe(JSON.stringify({ prompt: "What is truth?" }));
});
it("disables composer and renders Awaiting turn while pending", async () => {
vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {})));
const user = userEvent.setup();
renderRoute();
const textarea = screen.getByPlaceholderText("Ask CORE a question...");
await user.type(textarea, "What is truth?");
await user.click(screen.getByRole("button", { name: /submit/i }));
expect(screen.getByText("Awaiting turn...")).toBeInTheDocument();
expect(textarea).toBeDisabled();
});
it("renders response and evidence badges on success", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue({ ok: true, generated_at: "now", data: happyChatTurn }),
}),
);
const user = userEvent.setup();
renderRoute();
await user.type(screen.getByPlaceholderText("Ask CORE a question..."), "What is truth?");
await user.click(screen.getByRole("button", { name: /submit/i }));
expect(await screen.findByText(happyChatTurn.surface)).toBeInTheDocument();
expect(screen.getByText("Pack")).toBeInTheDocument();
expect(screen.getByText("Cleared")).toBeInTheDocument();
});
it("renders ErrorState fields on WorkbenchApiError", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue({
ok: false,
generated_at: "now",
error: { code: "bad_request", message: "prompt must be non-empty" },
}),
}),
);
const user = userEvent.setup();
renderRoute();
await user.type(screen.getByPlaceholderText("Ask CORE a question..."), "x");
await user.click(screen.getByRole("button", { name: /submit/i }));
expect(await screen.findByText("What failed")).toBeInTheDocument();
expect(screen.getByText("Mutation status")).toBeInTheDocument();
expect(screen.getByText("Reproducer")).toBeInTheDocument();
expect(screen.getByText("Retry safety")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,17 @@
import { Copy } from "lucide-react";
import { copyText } from "../../design/lib";
export function CopyableHash({ value, label = "trace_hash" }: { value: string; label?: string }) {
const short = value.length > 18 ? `${value.slice(0, 18)}...` : value;
return (
<button
type="button"
onClick={() => void copyText(value)}
className="inline-flex items-center gap-1 rounded border border-[var(--color-border-subtle)] bg-[var(--color-surface-inset)] px-2 py-1 font-mono text-xs text-[var(--color-text-secondary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
aria-label={`${label}: ${value}`}
>
<Copy size={12} aria-hidden />
{label}:{short}
</button>
);
}

View file

@ -0,0 +1,34 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { EvidenceStrip } from "./EvidenceStrip";
import { happyChatTurn, refusalChatTurn } from "./fixtures";
describe("EvidenceStrip", () => {
it("renders badges for a happy-path turn", () => {
render(<EvidenceStrip result={happyChatTurn} onOpen={vi.fn()} />);
expect(screen.getByText("Pack")).toBeInTheDocument();
expect(screen.getByText("Decoded")).toBeInTheDocument();
expect(screen.getByText("Cleared")).toBeInTheDocument();
expect(screen.getByText("Checkpoint")).toBeInTheDocument();
expect(screen.getByText("Runtime Turn")).toBeInTheDocument();
});
it("renders suppressed and refusal label for a refused turn", () => {
render(<EvidenceStrip result={refusalChatTurn} onOpen={vi.fn()} />);
expect(screen.getByText("Suppressed")).toBeInTheDocument();
expect(screen.getByText("Refusal")).toBeInTheDocument();
});
it("opens the drawer when a badge is clicked", async () => {
const user = userEvent.setup();
const onOpen = vi.fn();
render(<EvidenceStrip result={happyChatTurn} onOpen={onOpen} />);
await user.click(screen.getByLabelText("Open grounding evidence"));
expect(onOpen).toHaveBeenCalledWith("grounding");
});
});

View file

@ -0,0 +1,106 @@
import {
EpistemicState,
EpistemicStateBadge,
GroundingSource,
GroundingSourceBadge,
NormativeClearance,
NormativeClearanceBadge,
ReviewState,
ReviewStateBadge,
} from "../../design/components/badges";
import type { KeyboardEvent, ReactNode } from "react";
import type { ChatTurnResult } from "../../types/api";
import { CopyableHash } from "./CopyableHash";
export type TraceFocus =
| "metadata"
| "surfaces"
| "grounding"
| "verdicts"
| "proposals"
| "trace";
function BadgeRegion({
children,
onClick,
label,
}: {
children: ReactNode;
onClick: () => void;
label: string;
}) {
function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onClick();
}
}
return (
<div
role="button"
tabIndex={0}
aria-label={label}
onClickCapture={onClick}
onKeyDown={onKeyDown}
className="rounded focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
>
{children}
</div>
);
}
export function EvidenceStrip({
result,
onOpen,
}: {
result: ChatTurnResult;
onOpen: (focus: TraceFocus) => void;
}) {
return (
<div className="flex flex-wrap items-center gap-2" aria-label="Turn evidence">
<BadgeRegion label="Open grounding evidence" onClick={() => onOpen("grounding")}>
<GroundingSourceBadge value={result.grounding_source as GroundingSource} />
</BadgeRegion>
<BadgeRegion label="Open epistemic evidence" onClick={() => onOpen("grounding")}>
<EpistemicStateBadge value={result.epistemic_state as EpistemicState} />
</BadgeRegion>
<BadgeRegion label="Open clearance evidence" onClick={() => onOpen("verdicts")}>
<NormativeClearanceBadge value={result.normative_clearance as NormativeClearance} />
</BadgeRegion>
{result.refusal_emitted ? (
<button
type="button"
onClick={() => onOpen("verdicts")}
className="rounded border border-[var(--color-clearance-suppressed)] px-2 py-1 text-xs text-[var(--color-text-secondary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
>
Refusal
</button>
) : null}
<button
type="button"
onClick={() => onOpen("metadata")}
className="rounded border border-[var(--color-border-subtle)] px-2 py-1 text-xs text-[var(--color-text-secondary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
>
{result.checkpoint_emitted ? "Checkpoint" : "No checkpoint"}
</button>
<button
type="button"
onClick={() => onOpen("metadata")}
className="rounded border border-[var(--color-state-warning-border)] px-2 py-1 text-xs text-[var(--color-state-warning-text)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
>
Runtime Turn
</button>
{result.proposal_candidates.length > 0 ? (
<BadgeRegion label="Open proposal candidates" onClick={() => onOpen("proposals")}>
<ReviewStateBadge value={ReviewState.PENDING} />
</BadgeRegion>
) : null}
{result.trace_hash ? (
<span onClick={() => onOpen("trace")}>
<CopyableHash value={result.trace_hash} />
</span>
) : null}
</div>
);
}

View file

@ -0,0 +1,60 @@
import { Send, X } from "lucide-react";
import { FormEvent, KeyboardEvent, useState } from "react";
import { Button } from "../../design/components/primitives/Button";
const MAX_PROMPT_CHARS = 4096;
export function PromptComposer({
disabled,
onSubmit,
}: {
disabled: boolean;
onSubmit: (prompt: string) => void;
}) {
const [prompt, setPrompt] = useState("");
const overSoftCap = prompt.length >= MAX_PROMPT_CHARS * 0.8;
const invalid = prompt.trim().length === 0 || prompt.length > MAX_PROMPT_CHARS;
function submit(event?: FormEvent) {
event?.preventDefault();
if (!disabled && !invalid) onSubmit(prompt);
}
function onKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
submit();
}
if (event.key === "Escape") {
setPrompt("");
}
}
return (
<form onSubmit={submit} className="grid gap-2" aria-label="Chat prompt composer">
<textarea
value={prompt}
disabled={disabled}
maxLength={MAX_PROMPT_CHARS + 1}
onChange={(event) => setPrompt(event.target.value)}
onKeyDown={onKeyDown}
placeholder="Ask CORE a question..."
className="min-h-28 resize-y rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-inset)] p-3 text-sm text-[var(--color-text-primary)] outline-none focus-visible:border-[var(--color-border-strong)] focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)] disabled:opacity-60"
/>
<div className="flex items-center justify-between gap-3">
<div className="text-xs text-[var(--color-text-secondary)]">
{overSoftCap ? <span>{prompt.length}/{MAX_PROMPT_CHARS}</span> : null}
</div>
<div className="flex items-center gap-2">
<Button type="button" variant="quiet" disabled={disabled || prompt.length === 0} onClick={() => setPrompt("")}>
<X size={14} aria-hidden />
Clear
</Button>
<Button type="submit" disabled={disabled || invalid}>
<Send size={14} aria-hidden />
Submit
</Button>
</div>
</div>
</form>
);
}

View file

@ -0,0 +1,27 @@
import { Search } from "lucide-react";
import { Button } from "../../design/components/primitives/Button";
import type { ChatTurnResult } from "../../types/api";
import { EvidenceStrip, type TraceFocus } from "./EvidenceStrip";
export function ResponseCard({
result,
onOpenTrace,
}: {
result: ChatTurnResult;
onOpenTrace: (focus: TraceFocus) => void;
}) {
return (
<article className="grid gap-4 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] p-4">
<div className="prose prose-invert max-w-none text-sm text-[var(--color-text-primary)]">
{result.surface}
</div>
<EvidenceStrip result={result} onOpen={onOpenTrace} />
<div>
<Button type="button" variant="quiet" onClick={() => onOpenTrace("metadata")}>
<Search size={14} aria-hidden />
Open trace drawer
</Button>
</div>
</article>
);
}

View file

@ -0,0 +1,90 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
import { TraceDrawer } from "./TraceDrawer";
import { happyChatTurn, refusalChatTurn } from "./fixtures";
describe("TraceDrawer", () => {
it("renders all three layers from a ChatTurnResult", () => {
render(
<MemoryRouter>
<TraceDrawer result={happyChatTurn} open onOpenChange={vi.fn()} />
</MemoryRouter>,
);
expect(screen.getByText("Turn metadata")).toBeInTheDocument();
expect(screen.getByText("Surfaces")).toBeInTheDocument();
expect(screen.getByText("Grounding")).toBeInTheDocument();
expect(screen.getByText("Verdicts")).toBeInTheDocument();
expect(screen.getByText("Proposal candidates")).toBeInTheDocument();
expect(screen.getByText("Trace hash + replay")).toBeInTheDocument();
expect(screen.getByText("Stable JSON viewer")).toBeInTheDocument();
expect(screen.getByText("Raw payload")).toBeInTheDocument();
expect(screen.getByTestId("json-rows")).toBeInTheDocument();
});
it("surfaces layer-one field names", () => {
render(
<MemoryRouter>
<TraceDrawer result={happyChatTurn} open onOpenChange={vi.fn()} />
</MemoryRouter>,
);
expect(screen.getByText(/turn_cost_ms:/)).toBeInTheDocument();
expect(screen.getByText(/mutation_mode:/)).toBeInTheDocument();
expect(screen.getByText(/checkpoint_emitted:/)).toBeInTheDocument();
expect(screen.getByText("Final surface")).toBeInTheDocument();
expect(screen.getByText("Walk surface (telemetry)")).toBeInTheDocument();
expect(screen.getByText(/grounding_source:/)).toBeInTheDocument();
});
it("renders refusal panel with violated boundary detail", () => {
render(
<MemoryRouter>
<TraceDrawer result={refusalChatTurn} open onOpenChange={vi.fn()} />
</MemoryRouter>,
);
expect(screen.getByText("Refusal")).toBeInTheDocument();
expect(screen.getAllByText(/evidence_required/).length).toBeGreaterThan(0);
});
it("Esc closes the drawer", async () => {
const user = userEvent.setup();
const onOpenChange = vi.fn();
render(
<MemoryRouter>
<TraceDrawer result={happyChatTurn} open onOpenChange={onOpenChange} />
</MemoryRouter>,
);
await user.keyboard("{Escape}");
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("focuses close on open and restores focus on close", async () => {
function Harness() {
const [open, setOpen] = useState(false);
return (
<MemoryRouter>
<button type="button" onClick={() => setOpen(true)}>
Open trace drawer
</button>
<TraceDrawer result={happyChatTurn} open={open} onOpenChange={setOpen} />
</MemoryRouter>
);
}
const user = userEvent.setup();
render(<Harness />);
const trigger = screen.getByRole("button", { name: "Open trace drawer" });
trigger.focus();
await user.click(trigger);
await waitFor(() => expect(screen.getByRole("button", { name: "Close trace drawer" })).toHaveFocus());
await user.click(screen.getByRole("button", { name: "Close trace drawer" }));
await waitFor(() => expect(trigger).toHaveFocus());
});
});

View file

@ -0,0 +1,189 @@
import * as Dialog from "@radix-ui/react-dialog";
import { Download, X } from "lucide-react";
import { useEffect, useMemo, useRef, type ReactNode } from "react";
import { Link } from "react-router-dom";
import { Button } from "../../design/components/primitives/Button";
import { StableJsonViewer } from "../../design/components/StableJsonViewer";
import { copyText } from "../../design/lib";
import type { ChatTurnResult, TurnVerdict } from "../../types/api";
import { CopyableHash } from "./CopyableHash";
import type { TraceFocus } from "./EvidenceStrip";
function Panel({
id,
title,
children,
}: {
id: TraceFocus | "json" | "raw" | "refusal";
title: string;
children: ReactNode;
}) {
return (
<section id={`trace-${id}`} className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] p-3">
<h3 className="m-0 mb-2 text-sm font-semibold text-[var(--color-text-primary)]">{title}</h3>
{children}
</section>
);
}
function VerdictRow({ label, verdict }: { label: string; verdict: TurnVerdict | null }) {
return (
<div className="grid grid-cols-[7rem_1fr] gap-2 text-sm">
<dt className="text-[var(--color-text-secondary)]">{label}</dt>
<dd className="m-0">
{verdict ? (
<span>
{verdict.outcome}
{verdict.runtime_detail ? <span className="ml-2 font-mono text-xs">{verdict.runtime_detail}</span> : null}
</span>
) : (
"not emitted"
)}
</dd>
</div>
);
}
export function TraceDrawer({
result,
open,
focus,
onOpenChange,
}: {
result: ChatTurnResult | null;
open: boolean;
focus?: TraceFocus;
onOpenChange: (open: boolean) => void;
}) {
const closeRef = useRef<HTMLButtonElement>(null);
const restoreFocusRef = useRef<HTMLElement | null>(null);
const json = useMemo(() => JSON.stringify(result, null, 2), [result]);
const rawUrl = useMemo(() => {
if (!result) return "";
return URL.createObjectURL(new Blob([JSON.stringify(result, null, 2)], { type: "application/json" }));
}, [result]);
useEffect(() => {
if (open) {
restoreFocusRef.current = document.activeElement as HTMLElement;
} else {
restoreFocusRef.current?.focus();
}
}, [open]);
useEffect(() => {
if (!open || !focus) return;
document.getElementById(`trace-${focus}`)?.scrollIntoView({ block: "start" });
}, [focus, open]);
if (!result) return null;
const replayCommand = `core trace ${JSON.stringify(result.prompt)}`;
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/40" />
<Dialog.Content
aria-label="Trace drawer"
onOpenAutoFocus={(event) => {
event.preventDefault();
closeRef.current?.focus();
}}
onCloseAutoFocus={(event) => {
event.preventDefault();
restoreFocusRef.current?.focus();
}}
className="fixed right-0 top-0 flex h-screen w-[min(42rem,92vw)] flex-col border-l border-[var(--color-border-strong)] bg-[var(--color-surface-base)] shadow-[var(--shadow-floating)] motion-standard"
>
<div className="flex items-center justify-between border-b border-[var(--color-border-subtle)] p-4">
<Dialog.Title className="m-0 text-base font-semibold">Turn trace</Dialog.Title>
<Dialog.Description className="sr-only">Full evidence envelope for the latest chat turn.</Dialog.Description>
<Dialog.Close asChild>
<Button ref={closeRef} type="button" variant="quiet" aria-label="Close trace drawer">
<X size={16} aria-hidden />
Close
</Button>
</Dialog.Close>
</div>
<div className="grid gap-3 overflow-y-auto p-4">
{result.refusal_emitted ? (
<Panel id="refusal" title="Refusal">
<p className="m-0 text-sm text-[var(--color-text-secondary)]">
Refusal emitted. Boundary detail: <span className="font-mono">{result.normative_detail || "not specified"}</span>
</p>
</Panel>
) : null}
<Panel id="metadata" title="Turn metadata">
<dl className="m-0 grid gap-1 text-sm">
<div>turn_cost_ms: {result.turn_cost_ms}</div>
<div>mutation_mode: {result.mutation_mode}</div>
<div>checkpoint_emitted: {String(result.checkpoint_emitted)}</div>
</dl>
</Panel>
<Panel id="surfaces" title="Surfaces">
<dl className="m-0 grid gap-3 text-sm">
<div><dt className="font-semibold">Final surface</dt><dd className="m-0">{result.surface}</dd></div>
{result.articulation_surface && result.articulation_surface !== result.surface ? (
<div><dt className="font-semibold">Articulation surface</dt><dd className="m-0">{result.articulation_surface}</dd></div>
) : null}
<div><dt className="font-semibold">Walk surface (telemetry)</dt><dd className="m-0 font-mono text-xs">{result.walk_surface || "not emitted"}</dd></div>
</dl>
</Panel>
<Panel id="grounding" title="Grounding">
<p className="m-0 text-sm">grounding_source: {result.grounding_source}</p>
<p className="m-0 mt-1 text-sm text-[var(--color-text-secondary)]">epistemic_state: {result.epistemic_state}</p>
</Panel>
<Panel id="verdicts" title="Verdicts">
<dl className="m-0 grid gap-2">
<VerdictRow label="identity" verdict={result.identity_verdict} />
<VerdictRow label="safety" verdict={result.safety_verdict} />
<VerdictRow label="ethics" verdict={result.ethics_verdict} />
</dl>
</Panel>
<Panel id="proposals" title="Proposal candidates">
{result.proposal_candidates.length ? (
<div className="grid gap-2">
{result.proposal_candidates.map((candidate) => (
<div key={candidate.candidate_id} className="flex items-center justify-between gap-3 text-sm">
<span><span className="font-mono">{candidate.candidate_id}</span> {candidate.source_kind}</span>
<Link className="text-[var(--color-text-secondary)] underline" to={`/proposals/${candidate.candidate_id}`}>
/proposals/{candidate.candidate_id}
</Link>
</div>
))}
</div>
) : (
<p className="m-0 text-sm text-[var(--color-text-secondary)]">No proposal candidates.</p>
)}
</Panel>
<Panel id="trace" title="Trace hash + replay">
{result.trace_hash ? <CopyableHash value={result.trace_hash} /> : <p className="m-0 text-sm">No trace hash recorded.</p>}
<button
type="button"
className="mt-2 rounded border border-[var(--color-border-subtle)] px-2 py-1 font-mono text-xs text-[var(--color-text-secondary)]"
onClick={() => void copyText(replayCommand)}
>
Run: {replayCommand}
</button>
</Panel>
<Panel id="json" title="Stable JSON viewer">
<StableJsonViewer source={json} />
</Panel>
<Panel id="raw" title="Raw payload">
<details>
<summary className="cursor-pointer text-sm">Operator raw JSON</summary>
<a className="mt-2 inline-flex items-center gap-2 text-sm underline" href={rawUrl} download="chat-turn-result.json">
<Download size={14} aria-hidden />
Download .json
</a>
<pre className="mt-2 max-h-72 overflow-auto rounded bg-[var(--color-surface-inset)] p-2 text-xs">{json}</pre>
</details>
</Panel>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View file

@ -0,0 +1,36 @@
import type { ChatTurnResult } from "../../types/api";
export const happyChatTurn: ChatTurnResult = {
prompt: "What is truth?",
surface: "Truth is what is true. pack-grounded (en_core_cognition_v1).",
articulation_surface: "Truth is what is true.",
walk_surface: "truth -> true",
grounding_source: "pack",
epistemic_state: "decoded",
normative_clearance: "cleared",
normative_detail: "",
trace_hash: "sha256:0123456789abcdef0123456789abcdef",
refusal_emitted: false,
hedge_injected: false,
mutation_mode: "runtime_turn",
identity_verdict: { outcome: "cleared", runtime_detail: "" },
safety_verdict: { outcome: "cleared", runtime_detail: "" },
ethics_verdict: { outcome: "cleared", runtime_detail: "" },
proposal_candidates: [{ candidate_id: "cand_123", source_kind: "discovery" }],
turn_cost_ms: 42,
checkpoint_emitted: true,
};
export const refusalChatTurn: ChatTurnResult = {
...happyChatTurn,
prompt: "Tina makes $18.00 an hour.",
surface: "I don't know - insufficient grounding for that yet.",
grounding_source: "none",
epistemic_state: "undetermined",
normative_clearance: "suppressed",
normative_detail: "ethics:evidence_required",
refusal_emitted: true,
safety_verdict: { outcome: "cleared", runtime_detail: "" },
ethics_verdict: { outcome: "violated", runtime_detail: "evidence_required" },
proposal_candidates: [],
};

View file

@ -1,6 +1,6 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import type { ButtonHTMLAttributes } from "react";
import { forwardRef, type ButtonHTMLAttributes } from "react";
import { cn } from "../../lib";
const buttonVariants = cva(
@ -24,7 +24,10 @@ export interface ButtonProps
asChild?: boolean;
}
export function Button({ className, variant, asChild, ...props }: ButtonProps) {
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ className, variant, asChild, ...props },
ref,
) {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant }), className)} {...props} />;
}
return <Comp ref={ref} className={cn(buttonVariants({ variant }), className)} {...props} />;
});

View file

@ -0,0 +1,55 @@
import { useState } from "react";
import { WorkbenchApiError } from "../api/client";
import { useChatTurn } from "../api/queries";
import { EmptyState } from "../design/components/states/EmptyState";
import { ErrorState } from "../design/components/states/ErrorState";
import { LoadingState } from "../design/components/states/LoadingState";
import { PromptComposer } from "../app/chat/PromptComposer";
import { ResponseCard } from "../app/chat/ResponseCard";
import { TraceDrawer } from "../app/chat/TraceDrawer";
import type { TraceFocus } from "../app/chat/EvidenceStrip";
export function ChatRoute() {
const chatTurn = useChatTurn();
const [drawerOpen, setDrawerOpen] = useState(false);
const [drawerFocus, setDrawerFocus] = useState<TraceFocus>("metadata");
function openTrace(focus: TraceFocus) {
setDrawerFocus(focus);
setDrawerOpen(true);
}
const error = chatTurn.error;
return (
<div className="mx-auto grid max-w-5xl gap-4">
<PromptComposer disabled={chatTurn.isPending} onSubmit={(prompt) => chatTurn.mutate({ prompt })} />
{chatTurn.isPending ? <LoadingState label="Awaiting turn..." /> : null}
{chatTurn.isError ? (
<ErrorState
whatFailed={error instanceof WorkbenchApiError ? error.message : "Chat turn failed."}
mutationStatus="No corpus mutation occurred."
reproducer={`curl -X POST /chat/turn -d '{"prompt":"${chatTurn.variables?.prompt ?? ""}"}'`}
retrySafety={error instanceof WorkbenchApiError && error.code === "read_error" ? "Retry after reducing request size." : "Retry: safe"}
/>
) : null}
{!chatTurn.data && !chatTurn.isPending && !chatTurn.isError ? (
<EmptyState
statement="Ask CORE a question."
nextAction={{ kind: "cli", command: "core chat" }}
/>
) : null}
{chatTurn.data ? <ResponseCard result={chatTurn.data} onOpenTrace={openTrace} /> : null}
<TraceDrawer
result={chatTurn.data ?? null}
open={drawerOpen}
focus={drawerFocus}
onOpenChange={setDrawerOpen}
/>
</div>
);
}

View file

@ -1,10 +0,0 @@
import { EmptyState } from "../design/components/states/EmptyState";
export function ChatRoutePlaceholder() {
return (
<EmptyState
statement="Chat — no data loaded yet."
nextAction={{ kind: "cli", command: "core chat" }}
/>
);
}

View file

@ -23,6 +23,28 @@ const EXPECTED_PYTHON_FIELDS: Record<string, string[]> = {
"active_session_id",
"mutation_mode",
],
TurnVerdict: ["outcome", "runtime_detail"],
ProposalRef: ["candidate_id", "source_kind"],
ChatTurnResult: [
"prompt",
"surface",
"articulation_surface",
"walk_surface",
"grounding_source",
"epistemic_state",
"normative_clearance",
"normative_detail",
"trace_hash",
"refusal_emitted",
"hedge_injected",
"mutation_mode",
"identity_verdict",
"safety_verdict",
"ethics_verdict",
"proposal_candidates",
"turn_cost_ms",
"checkpoint_emitted",
],
ArtifactRef: ["artifact_id", "kind", "path", "digest", "created_at"],
// ArtifactDetail inherits from ArtifactRef in Python; snapshot only shows own fields.
ArtifactDetail: ["content_type", "content"],

View file

@ -11,6 +11,25 @@ export type ErrorCode =
export type Backend = "numpy" | "mlx" | "rust" | "unknown";
export type MutationMode = "read_only" | "runtime_turn";
export type GroundingSource = "pack" | "teaching" | "vault" | "partial" | "oov" | "none";
export type EpistemicState =
| "perceived"
| "evidenced"
| "evidenced_incomplete"
| "verified"
| "decoded"
| "decoded_unarticulated"
| "inferred"
| "unverified_possible"
| "unverified_novel"
| "contradicted"
| "ambiguous"
| "undetermined"
| "scope_boundary"
| "computationally_bounded"
| "epistemic_state_needed";
export type NormativeClearance = "cleared" | "violated" | "unassessable" | "suppressed";
export type TurnVerdictOutcome = "cleared" | "violated" | "unassessable";
export interface RuntimeStatus {
backend: Backend;
@ -22,6 +41,37 @@ export interface RuntimeStatus {
mutation_mode: MutationMode;
}
export interface TurnVerdict {
outcome: TurnVerdictOutcome;
runtime_detail: string;
}
export interface ProposalRef {
candidate_id: string;
source_kind: string;
}
export interface ChatTurnResult {
prompt: string;
surface: string;
articulation_surface: string | null;
walk_surface: string | null;
grounding_source: GroundingSource;
epistemic_state: EpistemicState;
normative_clearance: NormativeClearance;
normative_detail: string;
trace_hash: string | null;
refusal_emitted: boolean;
hedge_injected: boolean;
mutation_mode: MutationMode;
identity_verdict: TurnVerdict | null;
safety_verdict: TurnVerdict | null;
ethics_verdict: TurnVerdict | null;
proposal_candidates: ProposalRef[];
turn_cost_ms: number;
checkpoint_emitted: boolean;
}
export type ArtifactKind =
| "trace"
| "eval_result"

View file

@ -3,13 +3,27 @@
from __future__ import annotations
import json
import threading
import time
from dataclasses import dataclass
from typing import Any
from urllib.parse import parse_qs, unquote, urlparse
from chat.runtime import ChatRuntime
from core.epistemic_state import (
clearance_from_verdicts,
coerce_normative_clearance,
epistemic_state_for_grounding_source,
normative_detail_from_verdicts,
)
from workbench import readers
from workbench.readers import ArtifactTooLargeError
from workbench.schemas import error, ok
from workbench.schemas import ChatTurnResult, ProposalRef, TurnVerdict, error, ok
MAX_CHAT_BODY_BYTES = 64 * 1024
MAX_CHAT_PROMPT_CHARS = 4096
_CHAT_TURN_LOCK = threading.Lock()
@dataclass(frozen=True, slots=True)
@ -81,11 +95,155 @@ class WorkbenchApi:
except ValueError as exc:
return ApiResponse(400, error("bad_request", str(exc)))
return ApiResponse(200, ok(result))
if method == "POST" and path == "/chat/turn":
return self._chat_turn(body)
if method == "GET" and path.startswith("/trace/"):
return ApiResponse(404, error("not_found", "trace storage is not wired in W-026"))
if (
(method == "POST" and path == "/chat/turn")
or (method == "GET" and path.startswith("/replay/"))
):
if method == "GET" and path.startswith("/replay/"):
return ApiResponse(501, error("unsupported", "route is deferred beyond W-026"))
return ApiResponse(404, error("not_found", f"route not found: {method} {path}"))
def _chat_turn(self, body: bytes) -> ApiResponse:
"""Execute one live runtime turn.
ADR-0160 v1 is single-operator-local-only, so chat turns are serialized
through the module-level ``_CHAT_TURN_LOCK``.
"""
if len(body) > MAX_CHAT_BODY_BYTES:
return ApiResponse(
413,
error("read_error", f"chat request exceeds {MAX_CHAT_BODY_BYTES} byte limit"),
)
request = json.loads(body.decode("utf-8") or "{}")
if not isinstance(request, dict):
return ApiResponse(400, error("bad_request", "chat request must be an object"))
prompt = request.get("prompt")
if not isinstance(prompt, str):
return ApiResponse(400, error("bad_request", "prompt must be a string"))
stripped = prompt.strip()
if not stripped:
return ApiResponse(400, error("bad_request", "prompt must be non-empty"))
if len(prompt) > MAX_CHAT_PROMPT_CHARS:
return ApiResponse(
400,
error("bad_request", f"prompt exceeds {MAX_CHAT_PROMPT_CHARS} character limit"),
)
with _CHAT_TURN_LOCK:
started = time.perf_counter()
result = _run_chat_turn(prompt)
elapsed_ms = max(0, int(round((time.perf_counter() - started) * 1000)))
return ApiResponse(200, ok(_with_turn_cost(result, elapsed_ms)))
def _with_turn_cost(result: ChatTurnResult, turn_cost_ms: int) -> ChatTurnResult:
from dataclasses import replace
return replace(result, turn_cost_ms=turn_cost_ms)
def _coerce_grounding_source(value: object) -> str:
text = str(value or "none").strip().lower()
return text if text in {"pack", "teaching", "vault", "partial", "oov", "none"} else "none"
def _identity_verdict(identity_score: object | None) -> TurnVerdict | None:
if identity_score is None:
return None
flagged = bool(getattr(identity_score, "flagged", False))
axes = tuple(getattr(identity_score, "deviation_axes", ()) or ())
detail = ",".join(sorted(str(axis) for axis in axes))
return TurnVerdict(
outcome="violated" if flagged else "cleared",
runtime_detail=detail,
)
def _normative_verdict(verdict: object | None, *, ids_attr: str) -> TurnVerdict | None:
if verdict is None:
return None
upheld = bool(getattr(verdict, "upheld", True))
runtime_checkable_count = int(getattr(verdict, "runtime_checkable_count", 0) or 0)
ids = tuple(getattr(verdict, ids_attr, ()) or ())
if not upheld:
return TurnVerdict(
outcome="violated",
runtime_detail=",".join(sorted(str(item) for item in ids)),
)
if runtime_checkable_count <= 0:
return TurnVerdict(outcome="unassessable", runtime_detail="")
return TurnVerdict(outcome="cleared", runtime_detail="")
def _proposal_refs(runtime: ChatRuntime, before_ids: set[str]) -> list[ProposalRef]:
refs: list[ProposalRef] = []
for candidate in getattr(runtime, "_pending_candidates", ()) or ():
candidate_id = str(getattr(candidate, "candidate_id", "") or "")
if not candidate_id or candidate_id in before_ids:
continue
refs.append(ProposalRef(candidate_id=candidate_id, source_kind="discovery"))
return refs
def _run_chat_turn(prompt: str) -> ChatTurnResult:
runtime = ChatRuntime()
before_candidate_ids = {
str(getattr(candidate, "candidate_id", "") or "")
for candidate in getattr(runtime, "_pending_candidates", ()) or ()
}
checkpoint_emitted = False
original_checkpoint = runtime.checkpoint_engine_state
def tracked_checkpoint() -> None:
nonlocal checkpoint_emitted
checkpoint_emitted = True
original_checkpoint()
runtime.checkpoint_engine_state = tracked_checkpoint # type: ignore[method-assign]
response = runtime.chat(prompt)
turn_event = runtime.turn_log[-1] if runtime.turn_log else None
verdicts = getattr(response, "verdicts", None)
grounding_source = _coerce_grounding_source(getattr(response, "grounding_source", "none"))
normative_clearance = coerce_normative_clearance(
getattr(response, "normative_clearance", None)
or clearance_from_verdicts(verdicts)
).value
normative_detail = str(
getattr(response, "normative_detail", None)
or normative_detail_from_verdicts(verdicts)
or ""
)
refusal_emitted = bool(getattr(verdicts, "refusal_emitted", False))
if (
not refusal_emitted
and normative_clearance == "violated"
and response.surface.startswith("I don't know")
):
refusal_emitted = True
normative_clearance = "suppressed"
trace_hash = str(getattr(turn_event, "trace_hash", "") or "") if turn_event else ""
return ChatTurnResult(
prompt=prompt,
surface=response.surface,
articulation_surface=response.articulation_surface or None,
walk_surface=response.walk_surface or None,
grounding_source=grounding_source, # type: ignore[arg-type]
epistemic_state=epistemic_state_for_grounding_source(grounding_source).value, # type: ignore[arg-type]
normative_clearance=normative_clearance, # type: ignore[arg-type]
normative_detail=normative_detail,
trace_hash=trace_hash or None,
refusal_emitted=refusal_emitted,
hedge_injected=bool(getattr(verdicts, "hedge_injected", False)),
mutation_mode="runtime_turn",
identity_verdict=_identity_verdict(getattr(response, "identity_score", None)),
safety_verdict=_normative_verdict(
getattr(response, "safety_verdict", None),
ids_attr="violated_boundaries",
),
ethics_verdict=_normative_verdict(
getattr(response, "ethics_verdict", None),
ids_attr="violated_commitments",
),
proposal_candidates=_proposal_refs(runtime, before_candidate_ids),
turn_cost_ms=0,
checkpoint_emitted=checkpoint_emitted,
)

View file

@ -16,6 +16,32 @@ ErrorCode = Literal[
"runtime_unavailable",
]
MutationMode = Literal["read_only", "runtime_turn"]
GroundingSource = Literal["pack", "teaching", "vault", "partial", "oov", "none"]
EpistemicStateValue = Literal[
"perceived",
"evidenced",
"evidenced_incomplete",
"verified",
"decoded",
"decoded_unarticulated",
"inferred",
"unverified_possible",
"unverified_novel",
"contradicted",
"ambiguous",
"undetermined",
"scope_boundary",
"computationally_bounded",
"epistemic_state_needed",
]
NormativeClearanceValue = Literal[
"cleared",
"violated",
"unassessable",
"suppressed",
]
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
@ -52,7 +78,41 @@ class RuntimeStatus:
checkpoint_revision: str
revision_warning: bool
active_session_id: str | None
mutation_mode: Literal["read_only", "runtime_turn"] = "read_only"
mutation_mode: MutationMode = "read_only"
@dataclass(frozen=True, slots=True)
class TurnVerdict:
outcome: Literal["cleared", "violated", "unassessable"]
runtime_detail: str
@dataclass(frozen=True, slots=True)
class ProposalRef:
candidate_id: str
source_kind: str
@dataclass(frozen=True, slots=True)
class ChatTurnResult:
prompt: str
surface: str
articulation_surface: str | None
walk_surface: str | None
grounding_source: GroundingSource
epistemic_state: EpistemicStateValue
normative_clearance: NormativeClearanceValue
normative_detail: str
trace_hash: str | None
refusal_emitted: bool
hedge_injected: bool
mutation_mode: MutationMode
identity_verdict: TurnVerdict | None
safety_verdict: TurnVerdict | None
ethics_verdict: TurnVerdict | None
proposal_candidates: list[ProposalRef]
turn_cost_ms: int
checkpoint_emitted: bool
@dataclass(frozen=True, slots=True)

View file

@ -8,7 +8,7 @@ import os
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from workbench.api import WorkbenchApi
from workbench.api import MAX_CHAT_BODY_BYTES, WorkbenchApi
class WorkbenchRequestHandler(BaseHTTPRequestHandler):
@ -20,6 +20,11 @@ class WorkbenchRequestHandler(BaseHTTPRequestHandler):
def do_POST(self) -> None: # noqa: N802 - stdlib handler API
self._handle()
def do_OPTIONS(self) -> None: # noqa: N802 - stdlib handler API
self.send_response(204)
self._send_common_headers(0)
self.end_headers()
def log_message(self, format: str, *args: object) -> None:
if os.environ.get("CORE_WORKBENCH_QUIET") == "1":
return
@ -33,15 +38,28 @@ class WorkbenchRequestHandler(BaseHTTPRequestHandler):
length = max(0, int(self.headers.get("Content-Length") or "0"))
except ValueError:
length = 0
body = self.rfile.read(length) if length else b""
if (
self.command == "POST"
and self.path.split("?", 1)[0].rstrip("/") == "/chat/turn"
and length > MAX_CHAT_BODY_BYTES
):
body = b"x" * (MAX_CHAT_BODY_BYTES + 1)
else:
body = self.rfile.read(length) if length else b""
response = self.api.handle(self.command, self.path, body)
payload = json.dumps(response.payload, ensure_ascii=False, sort_keys=True).encode("utf-8")
self.send_response(response.status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(payload)))
self._send_common_headers(len(payload))
self.end_headers()
self.wfile.write(payload)
def _send_common_headers(self, content_length: int) -> None:
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(content_length))
self.send_header("Access-Control-Allow-Origin", "http://127.0.0.1:5173")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
def serve(*, host: str = "127.0.0.1", port: int = 8765) -> None:
server = ThreadingHTTPServer((host, port), WorkbenchRequestHandler)