feat(W-028): chat surface + trace drawer (#303)
This commit is contained in:
parent
e9b7eb0b1f
commit
a612038d41
29 changed files with 1399 additions and 54 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
168
tests/test_workbench_chat_turn.py
Normal file
168
tests/test_workbench_chat_turn.py
Normal 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"]
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
62
workbench-ui/src/api/chat-turn.test.tsx
Normal file
62
workbench-ui/src/api/chat-turn.test.tsx
Normal 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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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)]"
|
||||
|
|
|
|||
98
workbench-ui/src/app/chat/ChatRoute.test.tsx
Normal file
98
workbench-ui/src/app/chat/ChatRoute.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
17
workbench-ui/src/app/chat/CopyableHash.tsx
Normal file
17
workbench-ui/src/app/chat/CopyableHash.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
workbench-ui/src/app/chat/EvidenceStrip.test.tsx
Normal file
34
workbench-ui/src/app/chat/EvidenceStrip.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
106
workbench-ui/src/app/chat/EvidenceStrip.tsx
Normal file
106
workbench-ui/src/app/chat/EvidenceStrip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
workbench-ui/src/app/chat/PromptComposer.tsx
Normal file
60
workbench-ui/src/app/chat/PromptComposer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
workbench-ui/src/app/chat/ResponseCard.tsx
Normal file
27
workbench-ui/src/app/chat/ResponseCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
workbench-ui/src/app/chat/TraceDrawer.test.tsx
Normal file
90
workbench-ui/src/app/chat/TraceDrawer.test.tsx
Normal 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());
|
||||
});
|
||||
});
|
||||
189
workbench-ui/src/app/chat/TraceDrawer.tsx
Normal file
189
workbench-ui/src/app/chat/TraceDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
workbench-ui/src/app/chat/fixtures.ts
Normal file
36
workbench-ui/src/app/chat/fixtures.ts
Normal 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: [],
|
||||
};
|
||||
|
|
@ -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} />;
|
||||
});
|
||||
|
|
|
|||
55
workbench-ui/src/routes/ChatRoute.tsx
Normal file
55
workbench-ui/src/routes/ChatRoute.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
168
workbench/api.py
168
workbench/api.py
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue