From a612038d41e4ca43abba8ebbb20a1dff6a478292 Mon Sep 17 00:00:00 2001 From: Shay Date: Tue, 26 May 2026 13:22:11 -0700 Subject: [PATCH] feat(W-028): chat surface + trace drawer (#303) --- docs/workbench/api-contract-v1.md | 71 ++++++- scripts/dump-api-schemas.py | 10 +- tests/test_workbench_api.py | 6 +- tests/test_workbench_chat_turn.py | 168 ++++++++++++++++ workbench-ui/api-schema-snapshot.json | 36 +++- workbench-ui/src/api/chat-turn.test.tsx | 62 ++++++ workbench-ui/src/api/client.ts | 4 +- workbench-ui/src/api/queries.ts | 15 ++ workbench-ui/src/app/App.tsx | 4 +- workbench-ui/src/app/Shell.error.test.tsx | 4 +- workbench-ui/src/app/Shell.test.tsx | 7 +- workbench-ui/src/app/StatusFooter.tsx | 5 +- workbench-ui/src/app/chat/ChatRoute.test.tsx | 98 +++++++++ workbench-ui/src/app/chat/CopyableHash.tsx | 17 ++ .../src/app/chat/EvidenceStrip.test.tsx | 34 ++++ workbench-ui/src/app/chat/EvidenceStrip.tsx | 106 ++++++++++ workbench-ui/src/app/chat/PromptComposer.tsx | 60 ++++++ workbench-ui/src/app/chat/ResponseCard.tsx | 27 +++ .../src/app/chat/TraceDrawer.test.tsx | 90 +++++++++ workbench-ui/src/app/chat/TraceDrawer.tsx | 189 ++++++++++++++++++ workbench-ui/src/app/chat/fixtures.ts | 36 ++++ .../design/components/primitives/Button.tsx | 11 +- workbench-ui/src/routes/ChatRoute.tsx | 55 +++++ .../src/routes/ChatRoutePlaceholder.tsx | 10 - workbench-ui/src/types/api.test.ts | 22 ++ workbench-ui/src/types/api.ts | 50 +++++ workbench/api.py | 168 +++++++++++++++- workbench/schemas.py | 62 +++++- workbench/server.py | 26 ++- 29 files changed, 1399 insertions(+), 54 deletions(-) create mode 100644 tests/test_workbench_chat_turn.py create mode 100644 workbench-ui/src/api/chat-turn.test.tsx create mode 100644 workbench-ui/src/app/chat/ChatRoute.test.tsx create mode 100644 workbench-ui/src/app/chat/CopyableHash.tsx create mode 100644 workbench-ui/src/app/chat/EvidenceStrip.test.tsx create mode 100644 workbench-ui/src/app/chat/EvidenceStrip.tsx create mode 100644 workbench-ui/src/app/chat/PromptComposer.tsx create mode 100644 workbench-ui/src/app/chat/ResponseCard.tsx create mode 100644 workbench-ui/src/app/chat/TraceDrawer.test.tsx create mode 100644 workbench-ui/src/app/chat/TraceDrawer.tsx create mode 100644 workbench-ui/src/app/chat/fixtures.ts create mode 100644 workbench-ui/src/routes/ChatRoute.tsx delete mode 100644 workbench-ui/src/routes/ChatRoutePlaceholder.tsx diff --git a/docs/workbench/api-contract-v1.md b/docs/workbench/api-contract-v1.md index 78073240..aea423dc 100644 --- a/docs/workbench/api-contract-v1.md +++ b/docs/workbench/api-contract-v1.md @@ -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 diff --git a/scripts/dump-api-schemas.py b/scripts/dump-api-schemas.py index 803fb424..c3df9692 100644 --- a/scripts/dump-api-schemas.py +++ b/scripts/dump-api-schemas.py @@ -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__": diff --git a/tests/test_workbench_api.py b/tests/test_workbench_api.py index ddabef7f..f00dbc08 100644 --- a/tests/test_workbench_api.py +++ b/tests/test_workbench_api.py @@ -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: diff --git a/tests/test_workbench_chat_turn.py b/tests/test_workbench_chat_turn.py new file mode 100644 index 00000000..c815fca1 --- /dev/null +++ b/tests/test_workbench_chat_turn.py @@ -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"] diff --git a/workbench-ui/api-schema-snapshot.json b/workbench-ui/api-schema-snapshot.json index 29d0499a..fd448833 100644 --- a/workbench-ui/api-schema-snapshot.json +++ b/workbench-ui/api-schema-snapshot.json @@ -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": { diff --git a/workbench-ui/src/api/chat-turn.test.tsx b/workbench-ui/src/api/chat-turn.test.tsx new file mode 100644 index 00000000..5312fb9e --- /dev/null +++ b/workbench-ui/src/api/chat-turn.test.tsx @@ -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 ( + + {children} + + ); +} + +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 }); + }); +}); diff --git a/workbench-ui/src/api/client.ts b/workbench-ui/src/api/client.ts index 924b9e3f..13972447 100644 --- a/workbench-ui/src/api/client.ts +++ b/workbench-ui/src/api/client.ts @@ -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(path: string): Promise { +export async function apiFetch(path: string, init?: RequestInit): Promise { 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 = await res.json(); if (!json.ok) { throw new WorkbenchApiError(json.error.code, json.error.message); diff --git a/workbench-ui/src/api/queries.ts b/workbench-ui/src/api/queries.ts index 2a00b503..a648790f 100644 --- a/workbench-ui/src/api/queries.ts +++ b/workbench-ui/src/api/queries.ts @@ -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({ + mutationKey: ["chat-turn"], + mutationFn: ({ prompt }) => + apiFetch("/chat/turn", { + method: "POST", + body: JSON.stringify({ prompt }), + headers: { "Content-Type": "application/json" }, + }), + }); +} diff --git a/workbench-ui/src/app/App.tsx b/workbench-ui/src/app/App.tsx index 7d4aa1d3..d0001324 100644 --- a/workbench-ui/src/app/App.tsx +++ b/workbench-ui/src/app/App.tsx @@ -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() { }> } /> - } /> + } /> } /> } /> } /> diff --git a/workbench-ui/src/app/Shell.error.test.tsx b/workbench-ui/src/app/Shell.error.test.tsx index bfa24a89..bb71c067 100644 --- a/workbench-ui/src/app/Shell.error.test.tsx +++ b/workbench-ui/src/app/Shell.error.test.tsx @@ -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() { }> - } /> + } /> diff --git a/workbench-ui/src/app/Shell.test.tsx b/workbench-ui/src/app/Shell.test.tsx index f6e4f32a..10cf3910 100644 --- a/workbench-ui/src/app/Shell.test.tsx +++ b/workbench-ui/src/app/Shell.test.tsx @@ -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") { }> - } /> + } /> } /> @@ -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" }); diff --git a/workbench-ui/src/app/StatusFooter.tsx b/workbench-ui/src/app/StatusFooter.tsx index ffb01539..e7603ac9 100644 --- a/workbench-ui/src/app/StatusFooter.tsx +++ b/workbench-ui/src/app/StatusFooter.tsx @@ -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" ? ( + + + + , + ); +} + +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(); + }); +}); diff --git a/workbench-ui/src/app/chat/CopyableHash.tsx b/workbench-ui/src/app/chat/CopyableHash.tsx new file mode 100644 index 00000000..4d345e38 --- /dev/null +++ b/workbench-ui/src/app/chat/CopyableHash.tsx @@ -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 ( + + ); +} diff --git a/workbench-ui/src/app/chat/EvidenceStrip.test.tsx b/workbench-ui/src/app/chat/EvidenceStrip.test.tsx new file mode 100644 index 00000000..5b55b88d --- /dev/null +++ b/workbench-ui/src/app/chat/EvidenceStrip.test.tsx @@ -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(); + + 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(); + + 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(); + + await user.click(screen.getByLabelText("Open grounding evidence")); + + expect(onOpen).toHaveBeenCalledWith("grounding"); + }); +}); diff --git a/workbench-ui/src/app/chat/EvidenceStrip.tsx b/workbench-ui/src/app/chat/EvidenceStrip.tsx new file mode 100644 index 00000000..5bcb25c7 --- /dev/null +++ b/workbench-ui/src/app/chat/EvidenceStrip.tsx @@ -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) { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onClick(); + } + } + + return ( +
+ {children} +
+ ); +} + +export function EvidenceStrip({ + result, + onOpen, +}: { + result: ChatTurnResult; + onOpen: (focus: TraceFocus) => void; +}) { + return ( +
+ onOpen("grounding")}> + + + onOpen("grounding")}> + + + onOpen("verdicts")}> + + + {result.refusal_emitted ? ( + + ) : null} + + + {result.proposal_candidates.length > 0 ? ( + onOpen("proposals")}> + + + ) : null} + {result.trace_hash ? ( + onOpen("trace")}> + + + ) : null} +
+ ); +} diff --git a/workbench-ui/src/app/chat/PromptComposer.tsx b/workbench-ui/src/app/chat/PromptComposer.tsx new file mode 100644 index 00000000..86502f03 --- /dev/null +++ b/workbench-ui/src/app/chat/PromptComposer.tsx @@ -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) { + if ((event.metaKey || event.ctrlKey) && event.key === "Enter") { + submit(); + } + if (event.key === "Escape") { + setPrompt(""); + } + } + + return ( +
+