diff --git a/docs/plans/2026-04-29-agent-architecture-implementation.md b/docs/plans/2026-04-29-agent-architecture-implementation.md new file mode 100644 index 0000000..9d907db --- /dev/null +++ b/docs/plans/2026-04-29-agent-architecture-implementation.md @@ -0,0 +1,2368 @@ +# Controller Agent Architecture Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use the-controller-executing-plans to implement this plan task-by-task. + +**Goal:** Implement the approved agent architecture so the Controller browser talks only to the Controller backend, the daemon owns durable agent/chat/profile/turn state, and chat routing, agent creation, and observability can ship incrementally. + +**Architecture:** `the-controller` remains the browser-facing Axum + Svelte workspace owner. `the-controller-daemon` becomes the long-lived local agent owner behind a private Unix domain socket, with SQLite-backed profiles, chats, routing links, sessions, events, turns, and metrics. The browser calls same-origin `/api/daemon/...` routes only; the Controller backend forwards to the daemon over UDS and sends Controller-owned workspace snapshots when workspaces change. + +**Tech Stack:** Rust, axum, tokio, SQLite/sqlx in `the-controller-daemon`; Rust, axum, reqwest/hyper UDS gateway in `the-controller`; Svelte 5 runes, Vitest, Playwright. + +--- + +## Source Context + +Read before implementation: + +- `docs/domain-knowledge.md` +- `docs/plans/2026-04-29-agent-architecture-design.md` +- `docs/plans/2026-04-29-chat-routing-prd.md` +- `docs/plans/2026-04-29-agent-creation-prd.md` +- `docs/plans/2026-04-29-agent-observability-prd.md` +- `docs/plans/2026-04-29-controller-agent-product-prd.md` +- `/Users/noelkwan/projects/the-controller-daemon/docs/plans/2026-04-20-daemon-chat-architecture-design.md` +- `/Users/noelkwan/projects/the-controller-daemon/docs/plans/2026-04-26-adhoc-agent-profiles-design.md` + +This is a two-repository implementation: + +- Controller repo: `/Users/noelkwan/.codex/worktrees/7156/the-controller` +- Daemon repo: `/Users/noelkwan/projects/the-controller-daemon` + +No old-data migration is required by the architecture, but existing tests use current migrations. Prefer additive migrations while tests still exist; delete or rewrite old assumptions only when the relevant replacement test exists and fails first. + +## Required Execution Rules + +- Use TDD for every behavior change. +- Before each production edit, write the failing test and run it. +- Do not expose daemon socket path, token, port, or transport details to browser JavaScript. +- Do not add a daemon TCP fallback in the target architecture. +- Do not let the daemon create, delete, rename, or migrate Controller worktrees. +- Offload blocking Controller request-handler work with `tokio::task::spawn_blocking`. +- Commit after each task or after the smallest coherent green slice if a task spans both repositories. +- If a task says to edit the daemon repo, run commands from `/Users/noelkwan/projects/the-controller-daemon`. +- If a task says to edit the Controller repo, run commands from `/Users/noelkwan/.codex/worktrees/7156/the-controller`. + +## Baseline Setup + +### Task 1: Prepare Branches And Baseline Verification + +**Files:** +- Modify: no files expected unless dependency installation changes lockfiles. + +**Step 1: Confirm Controller branch** + +Run from the Controller repo: + +```bash +git status --short --branch +``` + +Expected: on a non-main branch such as `codex/agent-implementation-plan` with no unexpected changes except this plan file if already saved. + +**Step 2: Create daemon branch** + +Run from the daemon repo: + +```bash +git status --short --branch +git switch -c codex/agent-architecture-implementation +``` + +Expected: a new daemon branch. If the branch already exists, switch to it and verify it is not `main`. + +**Step 3: Run Controller baseline tests** + +Run from the Controller repo: + +```bash +pnpm test +cd server && cargo test +``` + +Expected: the current baseline result is known before implementation. If either command fails, stop and report the failure instead of continuing. + +**Step 4: Run daemon baseline tests** + +Run from the daemon repo: + +```bash +cargo test +``` + +Expected: the current daemon baseline result is known before implementation. If it fails, stop and report the failure instead of continuing. + +**Step 5: Commit only if setup changed files** + +If no files changed, do not commit. If lockfiles changed because dependencies were installed, inspect the diff and commit: + +```bash +git diff --stat +git add +git commit -m "chore: refresh implementation baseline" +``` + +## Transport And Gateway + +### Task 2: Move Daemon Config To Unix Socket + +**Files:** +- Modify: `/Users/noelkwan/projects/the-controller-daemon/src/config.rs` +- Modify: `/Users/noelkwan/projects/the-controller-daemon/src/main.rs` +- Create: `/Users/noelkwan/projects/the-controller-daemon/tests/uds_config.rs` +- Test: `/Users/noelkwan/projects/the-controller-daemon/tests/uds_config.rs` + +**Step 1: Write the failing config test** + +Create `tests/uds_config.rs`: + +```rust +use std::path::PathBuf; +use the_controller_daemon::config::Config; + +#[test] +fn default_config_uses_unix_socket_and_no_tcp_bind() { + std::env::remove_var("TCD_STATE_DIR"); + std::env::remove_var("TCD_BIND"); + std::env::remove_var("TCD_SOCKET"); + + let cfg = Config::default_from_home(); + + assert_eq!(cfg.socket_path, PathBuf::from(std::env::var("HOME").unwrap()).join(".the-controller/daemon.sock")); + assert!(!format!("{cfg:?}").contains("127.0.0.1:4867")); +} + +#[test] +fn state_dir_overrides_socket_path() { + let tmp = tempfile::tempdir().unwrap(); + std::env::set_var("TCD_STATE_DIR", tmp.path()); + std::env::remove_var("TCD_SOCKET"); + + let cfg = Config::from_env(); + + assert_eq!(cfg.socket_path, tmp.path().join("daemon.sock")); +} +``` + +**Step 2: Run test to verify it fails** + +Run: + +```bash +cargo test --test uds_config +``` + +Expected: FAIL because `Config` still has `bind_addr` and no `socket_path`. + +**Step 3: Implement minimal config change** + +In `src/config.rs`, replace `bind_addr` with `socket_path`: + +```rust +#[derive(Debug, Clone)] +pub struct Config { + pub state_dir: PathBuf, + pub db_path: PathBuf, + pub token_path: PathBuf, + pub pid_path: PathBuf, + pub socket_path: PathBuf, +} +``` + +`default_from_home()` sets `state_dir.join("daemon.sock")`. `from_env()` supports `TCD_SOCKET` for tests and local debugging only. Remove `TCD_BIND` support. + +**Step 4: Update main to bind UDS** + +In `src/main.rs`, replace `TcpListener::bind(&cfg.bind_addr)` with a Unix listener. Before binding, remove a stale socket file only when no live daemon owns the pid lock. + +Use a helper so it can be unit tested later: + +```rust +#[cfg(unix)] +fn remove_stale_socket(path: &std::path::Path) -> std::io::Result<()> { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(err), + } +} +``` + +If `axum::serve` cannot serve `tokio::net::UnixListener` directly, add a local listener wrapper or a small `serve_unix` helper using hyper. Keep that helper in `src/main.rs` until a second call site exists. + +**Step 5: Run transport tests** + +Run: + +```bash +cargo test --test uds_config +cargo test +``` + +Expected: PASS. + +**Step 6: Commit** + +Run: + +```bash +git add src/config.rs src/main.rs tests/uds_config.rs +git commit -m "feat: bind daemon to unix socket" +``` + +### Task 3: Add Controller Daemon Gateway Client + +**Files:** +- Modify: `server/Cargo.toml` +- Modify: `server/src/lib.rs` +- Create: `server/src/daemon_gateway.rs` +- Modify: `server/src/main.rs` +- Create: `server/tests/daemon_gateway.rs` +- Test: `server/tests/daemon_gateway.rs` + +**Step 1: Write the failing gateway test** + +Create `server/tests/daemon_gateway.rs` with a unit-level test for request construction. Keep it independent of a real daemon: + +```rust +use the_controller_lib::daemon_gateway::{DaemonGatewayConfig, daemon_socket_path}; + +#[test] +fn daemon_socket_path_uses_controller_state_dir() { + let tmp = tempfile::tempdir().unwrap(); + let cfg = DaemonGatewayConfig { + state_dir: tmp.path().to_path_buf(), + }; + + assert_eq!(daemon_socket_path(&cfg), tmp.path().join("daemon.sock")); +} +``` + +Add a second test once the gateway request builder exists: + +```rust +#[test] +fn gateway_paths_must_stay_under_api_daemon() { + assert!(the_controller_lib::daemon_gateway::normalize_daemon_path("/api/daemon/profiles").is_ok()); + assert!(the_controller_lib::daemon_gateway::normalize_daemon_path("/api/list_projects").is_err()); + assert!(the_controller_lib::daemon_gateway::normalize_daemon_path("/api/daemon/../projects").is_err()); +} +``` + +**Step 2: Run test to verify it fails** + +Run from the Controller repo: + +```bash +cd server && cargo test --test daemon_gateway +``` + +Expected: FAIL because `daemon_gateway` does not exist. + +**Step 3: Implement minimal gateway module** + +Create `server/src/daemon_gateway.rs`: + +```rust +use std::path::PathBuf; + +#[derive(Debug, Clone)] +pub struct DaemonGatewayConfig { + pub state_dir: PathBuf, +} + +pub fn daemon_socket_path(cfg: &DaemonGatewayConfig) -> PathBuf { + cfg.state_dir.join("daemon.sock") +} + +pub fn normalize_daemon_path(path: &str) -> Result { + let rest = path + .strip_prefix("/api/daemon") + .ok_or_else(|| "path must start with /api/daemon".to_string())?; + if rest.contains("..") { + return Err("daemon gateway path must not contain ..".to_string()); + } + Ok(if rest.is_empty() { "/".to_string() } else { rest.to_string() }) +} +``` + +Export it from `server/src/lib.rs`: + +```rust +pub mod daemon_gateway; +``` + +**Step 4: Add same-origin HTTP route skeleton** + +In `server/src/main.rs`, add a gateway route under `/api/daemon/{*path}`. Start with `GET /api/daemon/health` if wildcard routing is awkward in axum 0.8, then generalize in the next task. + +The handler must read the socket path from `state.app.storage.base_dir()` and must not read or return `daemon.token`. + +**Step 5: Run tests** + +Run: + +```bash +cd server && cargo test --test daemon_gateway +cd server && cargo test +``` + +Expected: PASS. + +**Step 6: Commit** + +Run: + +```bash +git add server/Cargo.toml server/src/lib.rs server/src/daemon_gateway.rs server/src/main.rs server/tests/daemon_gateway.rs +git commit -m "feat: add daemon gateway path handling" +``` + +### Task 4: Route Frontend Daemon Calls Through Same-Origin API + +**Files:** +- Modify: `src/lib/daemon/client.ts` +- Modify: `src/lib/daemon/store.svelte.ts` +- Modify: `src/lib/daemon/stream.ts` +- Modify: `src/lib/daemon/client.test.ts` +- Modify: `src/lib/daemon/store.test.ts` +- Modify: `src/lib/daemon/stream.test.ts` + +**Step 1: Write failing frontend client test** + +In `src/lib/daemon/client.test.ts`, replace the token-header expectation with: + +```ts +it("calls same-origin /api/daemon routes without exposing auth", async () => { + const fetchMock = mockFetch([{ status: 200, body: [] }]); + vi.stubGlobal("fetch", fetchMock); + + const c = new DaemonClient("/api/daemon"); + await c.listSessions(); + + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe("/api/daemon/sessions"); + expect(init.headers).not.toMatchObject({ Authorization: expect.any(String) }); +}); +``` + +Add: + +```ts +it("builds same-origin websocket URLs", () => { + const c = new DaemonClient("/api/daemon"); + expect(c.wsUrl("s1", 5)).toBe("/api/daemon/sessions/s1/stream?since=5"); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: + +```bash +pnpm test src/lib/daemon/client.test.ts +``` + +Expected: FAIL because `DaemonClient` requires a token and direct base URL. + +**Step 3: Implement minimal client change** + +Change `DaemonClient` constructor to: + +```ts +export class DaemonClient { + constructor(private baseUrl = "/api/daemon") {} +} +``` + +Remove `Authorization` header construction and remove `bearer`. + +Change `wsUrl()` so same-origin relative HTTP routes become relative WS gateway routes. The stream helper can pass the relative URL directly to `new WebSocket(url)`. + +**Step 4: Remove token bootstrap from store** + +In `src/lib/daemon/store.svelte.ts`: + +- Delete `token` from `StoreState`. +- Delete `read_daemon_token`. +- Construct `new DaemonClient("/api/daemon")`. +- Keep `pingDaemon()` using `listSessions()` or `/health`. + +**Step 5: Run frontend tests** + +Run: + +```bash +pnpm test src/lib/daemon/client.test.ts src/lib/daemon/store.test.ts src/lib/daemon/stream.test.ts +pnpm test +``` + +Expected: PASS. + +**Step 6: Commit** + +Run: + +```bash +git add src/lib/daemon/client.ts src/lib/daemon/store.svelte.ts src/lib/daemon/stream.ts src/lib/daemon/*.test.ts +git commit -m "feat: route daemon client through controller gateway" +``` + +## Durable Daemon Model + +### Task 5: Add Profile Versions, Handles, Archive State, And Avatar Metadata + +**Files:** +- Modify: `/Users/noelkwan/projects/the-controller-daemon/src/model.rs` +- Modify: `/Users/noelkwan/projects/the-controller-daemon/src/store/agents.rs` +- Modify: `/Users/noelkwan/projects/the-controller-daemon/src/api/agents.rs` +- Create: `/Users/noelkwan/projects/the-controller-daemon/migrations/0003_agent_architecture_profiles.sql` +- Create: `/Users/noelkwan/projects/the-controller-daemon/tests/store_profile_versions.rs` +- Create: `/Users/noelkwan/projects/the-controller-daemon/tests/http_profiles.rs` + +**Step 1: Write failing store test** + +Create `tests/store_profile_versions.rs`: + +```rust +use tempfile::TempDir; +use the_controller_daemon::{db, model::*, store::{agents::AgentProfileStore, skills::SkillStore}}; + +#[tokio::test] +async fn every_profile_save_creates_immutable_version() { + let tmp = TempDir::new().unwrap(); + let pool = db::open(&tmp.path().join("t.db")).await.unwrap(); + let agents = AgentProfileStore::new(pool); + + let created = agents.save_profile(NewAgentProfile { + id: None, + handle: "reviewer".into(), + name: "Reviewer".into(), + description: "Reviews code".into(), + runtime: Agent::Codex, + model: Some("gpt-5.3-codex".into()), + skills: vec![], + prompt: "Review carefully.".into(), + default_workspace_behavior: "focused".into(), + outbox_instructions: "Publish concise review notes.".into(), + }).await.unwrap(); + + let updated = agents.save_profile(NewAgentProfile { + id: Some(created.profile.id.clone()), + handle: "reviewer".into(), + name: "Reviewer".into(), + description: "Reviews code".into(), + runtime: Agent::Codex, + model: Some("gpt-5.3-codex".into()), + skills: vec![], + prompt: "Review even more carefully.".into(), + default_workspace_behavior: "focused".into(), + outbox_instructions: "Publish concise review notes.".into(), + }).await.unwrap(); + + assert_ne!(created.version.id, updated.version.id); + assert_eq!(agents.versions_for_profile(&created.profile.id).await.unwrap().len(), 2); + assert_eq!(agents.get_version(&created.version.id).await.unwrap().unwrap().prompt, "Review carefully."); +} +``` + +**Step 2: Run test to verify it fails** + +Run: + +```bash +cargo test --test store_profile_versions +``` + +Expected: FAIL because the store has mutable profiles and no versions. + +**Step 3: Add schema** + +Create `migrations/0003_agent_architecture_profiles.sql` with tables or columns for: + +```sql +ALTER TABLE agent_profiles ADD COLUMN handle TEXT; +ALTER TABLE agent_profiles ADD COLUMN archived_at INTEGER; +ALTER TABLE agent_profiles ADD COLUMN avatar_asset_path TEXT; +ALTER TABLE agent_profiles ADD COLUMN avatar_status TEXT NOT NULL DEFAULT 'initials'; +ALTER TABLE agent_profiles ADD COLUMN avatar_error TEXT; + +CREATE UNIQUE INDEX agent_profiles_active_handle +ON agent_profiles(handle) +WHERE archived_at IS NULL; + +CREATE TABLE agent_profile_versions ( + id TEXT PRIMARY KEY, + profile_id TEXT NOT NULL REFERENCES agent_profiles(id), + runtime TEXT NOT NULL CHECK (runtime IN ('codex')), + model TEXT, + prompt TEXT NOT NULL, + default_workspace_behavior TEXT NOT NULL, + outbox_instructions TEXT NOT NULL, + validation_result TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE TABLE agent_profile_version_skills ( + version_id TEXT NOT NULL REFERENCES agent_profile_versions(id) ON DELETE CASCADE, + skill_id TEXT NOT NULL, + position INTEGER NOT NULL CHECK (position >= 0), + PRIMARY KEY (version_id, skill_id), + UNIQUE (version_id, position), + FOREIGN KEY (skill_id) REFERENCES skill_library(id) +); +``` + +If SQLite cannot add a `NOT NULL` column without a default for existing tables, use defaults in the migration and backfill in Rust tests as needed. + +**Step 4: Update Rust model** + +Add: + +```rust +pub struct AgentProfileVersion { + pub id: String, + pub profile_id: String, + pub runtime: Agent, + pub model: Option, + pub prompt: String, + pub skills: Vec, + pub default_workspace_behavior: String, + pub outbox_instructions: String, + pub validation_result: serde_json::Value, + pub created_at: i64, +} + +pub struct SavedAgentProfile { + pub profile: AgentProfile, + pub version: AgentProfileVersion, +} +``` + +Change `AgentProfile` to include `handle`, archive fields, avatar fields, and `active_version_id`. + +**Step 5: Implement store APIs** + +In `src/store/agents.rs`, add: + +- `save_profile(NewAgentProfile) -> Result` +- `archive(id) -> Result` +- `restore(id) -> Result` +- `versions_for_profile(profile_id) -> Result>` +- `get_version(version_id) -> Result>` +- handle validation allowing lowercase letters, numbers, and hyphens. + +Keep old `create/update` only as wrappers if existing tests still need them. + +**Step 6: Add HTTP tests** + +Create `tests/http_profiles.rs` to prove: + +- `POST /profiles` creates profile and version. +- `PATCH /profiles/:id` creates a new version. +- `POST /profiles/:id/archive` hides it from active suggestions. +- `POST /profiles/:id/restore` restores it. +- handle collision among active profiles returns 422. + +**Step 7: Implement profile API routes** + +Create or adapt `src/api/agents.rs` so product routes are: + +```text +GET /profiles +POST /profiles +GET /profiles/:id +PATCH /profiles/:id +POST /profiles/:id/archive +POST /profiles/:id/restore +POST /profiles/:id/test-chat +``` + +Keep `/agents` aliases only if required by existing tests; mark them internal and remove after frontend migration. + +**Step 8: Run tests** + +Run: + +```bash +cargo test --test store_profile_versions +cargo test --test http_profiles +cargo test +``` + +Expected: PASS. + +**Step 9: Commit** + +Run from the daemon repo: + +```bash +git add src/model.rs src/store/agents.rs src/api/agents.rs migrations/0003_agent_architecture_profiles.sql tests/store_profile_versions.rs tests/http_profiles.rs +git commit -m "feat: version agent profiles" +``` + +### Task 6: Add Chats, Links, Workspace Snapshots, And Session Ownership + +**Files:** +- Modify: `/Users/noelkwan/projects/the-controller-daemon/src/model.rs` +- Modify: `/Users/noelkwan/projects/the-controller-daemon/src/store/mod.rs` +- Create: `/Users/noelkwan/projects/the-controller-daemon/src/store/chats.rs` +- Modify: `/Users/noelkwan/projects/the-controller-daemon/src/store/sessions.rs` +- Create: `/Users/noelkwan/projects/the-controller-daemon/migrations/0004_chats_and_routes.sql` +- Create: `/Users/noelkwan/projects/the-controller-daemon/tests/store_chats.rs` + +**Step 1: Write failing chat store test** + +Create `tests/store_chats.rs`: + +```rust +use tempfile::TempDir; +use the_controller_daemon::{db, model::*, store::chats::ChatStore}; + +#[tokio::test] +async fn creates_chat_and_workspace_snapshot_without_owning_workspace() { + let tmp = TempDir::new().unwrap(); + let pool = db::open(&tmp.path().join("t.db")).await.unwrap(); + let chats = ChatStore::new(pool); + + let chat = chats.create_chat(NewChat { + project_id: "controller-project".into(), + title: "Review branch".into(), + }).await.unwrap(); + + let link = chats.add_workspace_link(&chat.id, NewChatWorkspaceLink { + project_id: "controller-project".into(), + workspace_id: "controller-workspace".into(), + path: "/tmp/worktree".into(), + label: "controller-chat-routing".into(), + branch: Some("codex/chat-routing".into()), + focused: true, + }).await.unwrap(); + + assert_eq!(link.path, "/tmp/worktree"); + assert!(link.focused); + assert_eq!(chats.workspace_links(&chat.id).await.unwrap().len(), 1); +} +``` + +**Step 2: Run test to verify it fails** + +Run: + +```bash +cargo test --test store_chats +``` + +Expected: FAIL because `ChatStore` does not exist. + +**Step 3: Add schema** + +Create `migrations/0004_chats_and_routes.sql`: + +```sql +CREATE TABLE chats ( + id TEXT PRIMARY KEY, + controller_project_id TEXT NOT NULL, + title TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + deleted_at INTEGER +); + +CREATE TABLE chat_messages ( + id TEXT PRIMARY KEY, + chat_id TEXT NOT NULL REFERENCES chats(id), + idempotency_id TEXT, + body TEXT NOT NULL, + token_spans TEXT NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE(chat_id, idempotency_id) +); + +CREATE TABLE chat_agent_links ( + id TEXT PRIMARY KEY, + chat_id TEXT NOT NULL REFERENCES chats(id), + session_id TEXT NOT NULL REFERENCES sessions(id), + profile_id TEXT NOT NULL REFERENCES agent_profiles(id), + profile_version_id TEXT NOT NULL REFERENCES agent_profile_versions(id), + route_type TEXT NOT NULL CHECK(route_type IN ('reusable','shadow')), + focused INTEGER NOT NULL DEFAULT 0, + token_source TEXT NOT NULL CHECK(token_source IN ('@agent','%agent','api')), + created_at INTEGER NOT NULL +); + +CREATE TABLE chat_workspace_links ( + id TEXT PRIMARY KEY, + chat_id TEXT NOT NULL REFERENCES chats(id), + controller_project_id TEXT NOT NULL, + controller_workspace_id TEXT NOT NULL, + path TEXT NOT NULL, + label TEXT NOT NULL, + branch TEXT, + focused INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +ALTER TABLE sessions ADD COLUMN session_kind TEXT NOT NULL DEFAULT 'raw'; +ALTER TABLE sessions ADD COLUMN owner_chat_id TEXT REFERENCES chats(id); +ALTER TABLE sessions ADD COLUMN profile_version_id TEXT REFERENCES agent_profile_versions(id); +ALTER TABLE sessions ADD COLUMN launch_context_snapshot TEXT; +``` + +**Step 4: Implement models and store** + +Add model structs: + +- `Chat` +- `NewChat` +- `ChatMessage` +- `NewChatMessage` +- `ChatAgentLink` +- `NewChatAgentLink` +- `ChatWorkspaceLink` +- `NewChatWorkspaceLink` +- `SessionKind` + +Implement `ChatStore` with methods: + +- `create_chat` +- `get_chat` +- `list_chats` +- `mark_deleted` +- `append_message` +- `add_agent_link` +- `agent_links` +- `add_workspace_link` +- `workspace_links` +- `set_workspace_focus` + +**Step 5: Update SessionStore** + +Update `NewSession` and `Session` with: + +- `session_kind` +- `owner_chat_id` +- `profile_version_id` +- `launch_context_snapshot` + +Do not let `SessionStore` infer workspace paths. It stores only daemon-owned launch metadata. + +**Step 6: Run tests** + +Run: + +```bash +cargo test --test store_chats +cargo test +``` + +Expected: PASS. + +**Step 7: Commit** + +Run: + +```bash +git add src/model.rs src/store/mod.rs src/store/chats.rs src/store/sessions.rs migrations/0004_chats_and_routes.sql tests/store_chats.rs +git commit -m "feat: store chats and routing links" +``` + +### Task 7: Add Turns, Turn Events, And Metrics Store + +**Files:** +- Modify: `/Users/noelkwan/projects/the-controller-daemon/src/model.rs` +- Modify: `/Users/noelkwan/projects/the-controller-daemon/src/store/mod.rs` +- Modify: `/Users/noelkwan/projects/the-controller-daemon/src/store/events.rs` +- Create: `/Users/noelkwan/projects/the-controller-daemon/src/store/turns.rs` +- Create: `/Users/noelkwan/projects/the-controller-daemon/migrations/0005_turns_and_metrics.sql` +- Create: `/Users/noelkwan/projects/the-controller-daemon/tests/store_turns.rs` + +**Step 1: Write failing turns test** + +Create `tests/store_turns.rs`: + +```rust +use tempfile::TempDir; +use the_controller_daemon::{db, model::*, store::{events::EventStore, sessions::SessionStore, turns::TurnStore}}; + +#[tokio::test] +async fn turn_starts_at_inbox_and_events_attach_to_turn() { + let tmp = TempDir::new().unwrap(); + let pool = db::open(&tmp.path().join("t.db")).await.unwrap(); + let sessions = SessionStore::new(pool.clone()); + let events = EventStore::new(pool.clone()); + let turns = TurnStore::new(pool); + + let session = sessions.create(NewSession { + id: "s1".into(), + label: "reviewer".into(), + agent: Agent::Codex, + agent_profile_id: None, + profile_version_id: None, + session_kind: SessionKind::Reusable, + owner_chat_id: None, + cwd: "/tmp".into(), + args: vec![], + launch_context_snapshot: None, + }).await.unwrap(); + + let inbox = events.append_for_chat(&session.id, Some("chat1"), None, NewEvent { + channel: Channel::Inbox, + kind: "user_text".into(), + payload: serde_json::json!({"text": "review"}), + }).await.unwrap(); + + let turn = turns.create_for_inbox("chat1", &session.id, "msg1", inbox.seq).await.unwrap(); + events.attach_turn(&session.id, inbox.seq, &turn.id).await.unwrap(); + + let outbox = events.append_for_chat(&session.id, Some("chat1"), Some(&turn.id), NewEvent { + channel: Channel::Outbox, + kind: "agent_text".into(), + payload: serde_json::json!({"text": "looks good"}), + }).await.unwrap(); + + let trace = turns.trace_for_session(&session.id).await.unwrap(); + assert_eq!(trace[0].turn.id, turn.id); + assert_eq!(trace[0].events[0].seq, inbox.seq); + assert_eq!(trace[0].events[1].seq, outbox.seq); +} +``` + +**Step 2: Run test to verify it fails** + +Run: + +```bash +cargo test --test store_turns +``` + +Expected: FAIL because turn storage and event fields do not exist. + +**Step 3: Add schema** + +Create `migrations/0005_turns_and_metrics.sql`: + +```sql +ALTER TABLE events ADD COLUMN chat_id TEXT REFERENCES chats(id); +ALTER TABLE events ADD COLUMN turn_id TEXT; + +CREATE TABLE agent_turns ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions(id), + chat_id TEXT NOT NULL REFERENCES chats(id), + chat_message_id TEXT NOT NULL REFERENCES chat_messages(id), + inbox_seq INTEGER NOT NULL, + status TEXT NOT NULL, + received_at INTEGER NOT NULL, + activity_started_at INTEGER, + ended_at INTEGER +); + +CREATE INDEX agent_turns_session ON agent_turns(session_id, received_at DESC); +CREATE INDEX agent_turns_chat ON agent_turns(chat_id, received_at DESC); + +CREATE TABLE turn_metrics ( + turn_id TEXT PRIMARY KEY REFERENCES agent_turns(id) ON DELETE CASCADE, + input_tokens INTEGER, + output_tokens INTEGER, + cache_read_tokens INTEGER, + cache_write_tokens INTEGER, + tool_call_count INTEGER NOT NULL DEFAULT 0, + outbox_write_count INTEGER NOT NULL DEFAULT 0, + error_count INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL +); +``` + +**Step 4: Implement EventStore extensions** + +Add: + +- `append_for_chat(session_id, chat_id, turn_id, NewEvent)` +- `attach_turn(session_id, seq, turn_id)` +- `read_by_chat(chat_id, since, channels)` +- `read_by_turn(turn_id)` + +Keep existing `append()` as a wrapper for non-chat session events. + +**Step 5: Implement TurnStore** + +Implement: + +- `create_for_inbox(chat_id, session_id, chat_message_id, inbox_seq)` +- `mark_activity_started(turn_id)` +- `complete(turn_id, status)` +- `record_token_usage(turn_id, usage)` +- `increment_tool_calls(turn_id)` +- `increment_outbox_writes(turn_id)` +- `increment_errors(turn_id)` +- `trace_for_session(session_id)` +- `trace_for_chat(chat_id)` + +**Step 6: Run tests** + +Run: + +```bash +cargo test --test store_turns +cargo test +``` + +Expected: PASS. + +**Step 7: Commit** + +Run: + +```bash +git add src/model.rs src/store/mod.rs src/store/events.rs src/store/turns.rs migrations/0005_turns_and_metrics.sql tests/store_turns.rs +git commit -m "feat: store agent turns and metrics" +``` + +## Chat Routing API + +### Task 8: Implement Chat Creation, Transcript Reads, And Streams + +**Files:** +- Modify: `/Users/noelkwan/projects/the-controller-daemon/src/api/mod.rs` +- Modify: `/Users/noelkwan/projects/the-controller-daemon/src/api/router.rs` +- Create: `/Users/noelkwan/projects/the-controller-daemon/src/api/chats.rs` +- Modify: `/Users/noelkwan/projects/the-controller-daemon/src/state.rs` +- Modify: `/Users/noelkwan/projects/the-controller-daemon/tests/common/mod.rs` +- Create: `/Users/noelkwan/projects/the-controller-daemon/tests/http_chats.rs` + +**Step 1: Write failing HTTP test** + +Create `tests/http_chats.rs`: + +```rust +mod common; + +#[tokio::test] +async fn creates_chat_without_starting_agent_session() { + let (_tmp, base, tok) = common::start().await; + let c = reqwest::Client::new(); + + let r = c.post(format!("{base}/chats")) + .bearer_auth(&tok) + .json(&serde_json::json!({ + "project_id": "controller-project", + "title": "New chat" + })) + .send() + .await + .unwrap(); + + assert_eq!(r.status(), 201); + let chat: serde_json::Value = r.json().await.unwrap(); + assert_eq!(chat["project_id"], "controller-project"); + + let sessions: Vec = c.get(format!("{base}/sessions")) + .bearer_auth(&tok) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert!(sessions.is_empty()); +} +``` + +**Step 2: Run test to verify it fails** + +Run: + +```bash +cargo test --test http_chats creates_chat_without_starting_agent_session +``` + +Expected: FAIL because `/chats` does not exist. + +**Step 3: Add ChatStore to AppState** + +In `src/state.rs`, add: + +```rust +pub chats: ChatStore, +pub turns: TurnStore, +``` + +Update `src/main.rs` and `tests/common/mod.rs` to construct both stores. + +**Step 4: Implement routes** + +Create `src/api/chats.rs` with: + +```text +GET /chats +POST /chats +GET /chats/:id +DELETE /chats/:id +GET /chats/:id/transcript +GET /chats/:id/stream +POST /chats/:id/workspace-links +PATCH /chats/:id/workspace-links/:link_id/focus +``` + +For this task, implement only `GET/POST/GET transcript/stream` and return `501 Not Implemented` for message send, deletion, and workspace mutation until later tasks. + +Transcript should return chat user messages plus outbox events only. Do not include raw runtime diagnostics. + +**Step 5: Add stream replay** + +`GET /chats/:id/stream` should subscribe before replay, like the existing session stream. It must replay by chat cursor and then live-publish durable chat events. + +**Step 6: Run tests** + +Run: + +```bash +cargo test --test http_chats +cargo test +``` + +Expected: PASS. + +**Step 7: Commit** + +Run: + +```bash +git add src/api/mod.rs src/api/router.rs src/api/chats.rs src/state.rs src/main.rs tests/common/mod.rs tests/http_chats.rs +git commit -m "feat: add chat API shell" +``` + +### Task 9: Implement Reusable Agent Linking And Fan-Out + +**Files:** +- Modify: `/Users/noelkwan/projects/the-controller-daemon/src/api/chats.rs` +- Modify: `/Users/noelkwan/projects/the-controller-daemon/src/api/agents.rs` +- Modify: `/Users/noelkwan/projects/the-controller-daemon/src/api/sessions.rs` +- Modify: `/Users/noelkwan/projects/the-controller-daemon/src/supervisor.rs` +- Modify: `/Users/noelkwan/projects/the-controller-daemon/tests/http_chats.rs` + +**Step 1: Write failing fan-out test** + +Add to `tests/http_chats.rs`: + +```rust +#[tokio::test] +async fn sending_message_creates_one_inbox_and_turn_per_linked_reusable_agent() { + let (_tmp, base, tok, _guard) = common::start_with_env(vec![ + ("TCD_AGENT_CODEX_BINARY", env!("CARGO_BIN_EXE_fake_agent")), + ("TCD_AGENT_CODEX_SCRIPT", "hold"), + ]).await; + let c = reqwest::Client::new(); + + common::register_profile(&c, &base, &tok, _tmp.path(), "reviewer").await; + let chat = common::create_chat(&c, &base, &tok).await; + + let r = c.post(format!("{base}/chats/{}/messages", chat["id"].as_str().unwrap())) + .bearer_auth(&tok) + .json(&serde_json::json!({ + "idempotency_id": "client-1", + "body": "Please review @reviewer", + "tokens": [ + { "kind": "reusable", "handle": "reviewer", "start": 14, "end": 23 } + ] + })) + .send() + .await + .unwrap(); + + assert_eq!(r.status(), 202); + let accepted: serde_json::Value = r.json().await.unwrap(); + assert_eq!(accepted["turns"].as_array().unwrap().len(), 1); + + let events: Vec = c.get(format!("{base}/sessions/{}/messages", accepted["turns"][0]["session_id"].as_str().unwrap())) + .bearer_auth(&tok) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert!(events.iter().any(|e| e["channel"] == "inbox" && e["kind"] == "user_text")); +} +``` + +Add helper functions in `tests/common/mod.rs` only after this test fails. + +**Step 2: Run test to verify it fails** + +Run: + +```bash +cargo test --test http_chats sending_message_creates_one_inbox_and_turn_per_linked_reusable_agent +``` + +Expected: FAIL because message send/fan-out is missing. + +**Step 3: Implement token request types** + +In `src/api/chats.rs`, define: + +```rust +#[derive(Deserialize)] +struct SendChatMessageBody { + idempotency_id: Option, + body: String, + #[serde(default)] + tokens: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case", tag = "kind")] +enum RouteToken { + Reusable { handle: String, start: usize, end: usize }, + Shadow { handle: String, start: usize, end: usize }, +} +``` + +**Step 4: Resolve reusable profiles** + +For each reusable token: + +- resolve active profile by handle; +- create or reuse a reusable session for that profile/version; +- insert or update `chat_agent_links`; +- set focused link for the last reusable token in the message. + +**Step 5: Fan out to all linked agents** + +For every current link: + +- append the chat message once; +- append one inbox event per linked session with `chat_id`; +- create one `agent_turn`; +- attach the inbox event to that turn; +- call `dispatch_pending()` on the matching supervisor if live. + +**Step 6: Run tests** + +Run: + +```bash +cargo test --test http_chats sending_message_creates_one_inbox_and_turn_per_linked_reusable_agent +cargo test --test http_chats +cargo test +``` + +Expected: PASS. + +**Step 7: Commit** + +Run: + +```bash +git add src/api/chats.rs src/api/agents.rs src/api/sessions.rs src/supervisor.rs tests/http_chats.rs tests/common/mod.rs +git commit -m "feat: fan out chat messages to reusable agents" +``` + +### Task 10: Implement Shadow Sessions And Chat Deletion Cleanup + +**Files:** +- Modify: `/Users/noelkwan/projects/the-controller-daemon/src/api/chats.rs` +- Modify: `/Users/noelkwan/projects/the-controller-daemon/src/api/sessions.rs` +- Modify: `/Users/noelkwan/projects/the-controller-daemon/src/store/chats.rs` +- Modify: `/Users/noelkwan/projects/the-controller-daemon/tests/http_chats.rs` + +**Step 1: Write failing shadow spawn test** + +Add: + +```rust +#[tokio::test] +async fn shadow_agent_spawns_on_send_and_is_stopped_on_chat_delete() { + let (_tmp, base, tok, _guard) = common::start_with_env(vec![ + ("TCD_AGENT_CODEX_BINARY", env!("CARGO_BIN_EXE_fake_agent")), + ("TCD_AGENT_CODEX_SCRIPT", "hold"), + ]).await; + let c = reqwest::Client::new(); + common::register_profile(&c, &base, &tok, _tmp.path(), "debugger").await; + let chat = common::create_chat(&c, &base, &tok).await; + let chat_id = chat["id"].as_str().unwrap(); + + let r = c.post(format!("{base}/chats/{chat_id}/messages")) + .bearer_auth(&tok) + .json(&serde_json::json!({ + "body": "Investigate %debugger", + "tokens": [{ "kind": "shadow", "handle": "debugger", "start": 12, "end": 21 }] + })) + .send() + .await + .unwrap(); + assert_eq!(r.status(), 202); + let accepted: serde_json::Value = r.json().await.unwrap(); + let session_id = accepted["turns"][0]["session_id"].as_str().unwrap(); + + c.delete(format!("{base}/chats/{chat_id}")) + .bearer_auth(&tok) + .send() + .await + .unwrap(); + + let session: serde_json::Value = c.get(format!("{base}/sessions/{session_id}")) + .bearer_auth(&tok) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(session["status"], "ended"); + assert_eq!(session["end_reason"], "owner_chat_deleted"); +} +``` + +**Step 2: Run test to verify it fails** + +Run: + +```bash +cargo test --test http_chats shadow_agent_spawns_on_send_and_is_stopped_on_chat_delete +``` + +Expected: FAIL because shadow ownership and cleanup are missing. + +**Step 3: Implement shadow token handling** + +For each `%agent` token: + +- resolve active profile by handle; +- create a new session with `session_kind = Shadow`; +- set `owner_chat_id = chat.id`; +- link it to the chat with `route_type = shadow`; +- do not reuse existing shadow sessions. + +**Step 4: Implement delete semantics** + +`DELETE /chats/:id` must: + +- load `chat_agent_links`; +- for shadow links, call supervisor `stop()` and set session end reason to `owner_chat_deleted`; +- for reusable links, leave sessions running; +- mark chat deleted; +- prevent deleted chats from accepting new messages. + +**Step 5: Run tests** + +Run: + +```bash +cargo test --test http_chats shadow_agent_spawns_on_send_and_is_stopped_on_chat_delete +cargo test --test http_chats +cargo test +``` + +Expected: PASS. + +**Step 6: Commit** + +Run: + +```bash +git add src/api/chats.rs src/api/sessions.rs src/store/chats.rs tests/http_chats.rs +git commit -m "feat: manage shadow chat sessions" +``` + +### Task 11: Add Workspace Link API And Context Snapshots + +**Files:** +- Modify: `/Users/noelkwan/projects/the-controller-daemon/src/api/chats.rs` +- Modify: `/Users/noelkwan/projects/the-controller-daemon/src/store/chats.rs` +- Modify: `/Users/noelkwan/projects/the-controller-daemon/src/profile_fs.rs` +- Modify: `/Users/noelkwan/projects/the-controller-daemon/tests/http_chats.rs` + +**Step 1: Write failing workspace link test** + +Add: + +```rust +#[tokio::test] +async fn workspace_link_stores_controller_snapshot_and_focus() { + let (_tmp, base, tok) = common::start().await; + let c = reqwest::Client::new(); + let chat = common::create_chat(&c, &base, &tok).await; + let chat_id = chat["id"].as_str().unwrap(); + + let r = c.post(format!("{base}/chats/{chat_id}/workspace-links")) + .bearer_auth(&tok) + .json(&serde_json::json!({ + "project_id": "controller-project", + "workspace_id": "workspace-1", + "path": "/tmp/controller-worktree", + "label": "controller-worktree", + "branch": "codex/chat-routing", + "focused": true + })) + .send() + .await + .unwrap(); + + assert_eq!(r.status(), 201); + let link: serde_json::Value = r.json().await.unwrap(); + assert_eq!(link["path"], "/tmp/controller-worktree"); + assert_eq!(link["focused"], true); +} +``` + +**Step 2: Run test to verify it fails** + +Run: + +```bash +cargo test --test http_chats workspace_link_stores_controller_snapshot_and_focus +``` + +Expected: FAIL because workspace link routes are missing. + +**Step 3: Implement link routes** + +Implement: + +```text +POST /chats/:id/workspace-links +PATCH /chats/:id/workspace-links/:link_id/focus +``` + +Validate: + +- `path` must be absolute. +- daemon stores the path but does not create or delete it. +- focus change clears previous focused workspace links for the chat. + +**Step 4: Add context rendering helper** + +In `profile_fs.rs`, add a pure function: + +```rust +pub fn render_workspace_context(links: &[ChatWorkspaceLink]) -> String +``` + +It returns deterministic Markdown for AGENTS.md updates. The function should not write files in this task. + +**Step 5: Run tests** + +Run: + +```bash +cargo test --test http_chats workspace_link_stores_controller_snapshot_and_focus +cargo test +``` + +Expected: PASS. + +**Step 6: Commit** + +Run: + +```bash +git add src/api/chats.rs src/store/chats.rs src/profile_fs.rs tests/http_chats.rs +git commit -m "feat: store chat workspace snapshots" +``` + +## Controller Backend Integration + +### Task 12: Proxy HTTP Gateway Requests Over UDS + +**Files:** +- Modify: `server/Cargo.toml` +- Modify: `server/src/daemon_gateway.rs` +- Modify: `server/src/main.rs` +- Modify: `server/tests/daemon_gateway.rs` + +**Step 1: Write failing proxy test** + +In `server/tests/daemon_gateway.rs`, add a test that starts a local Unix socket test server and verifies `GET /health` is forwarded without exposing auth to the caller. If a full axum gateway integration test is too heavy, test the gateway client function directly: + +```rust +#[tokio::test] +async fn gateway_health_uses_unix_socket() { + let tmp = tempfile::tempdir().unwrap(); + let socket = tmp.path().join("daemon.sock"); + let server = the_controller_lib::daemon_gateway::test_support::serve_uds_health(&socket).await; + + let body = the_controller_lib::daemon_gateway::forward_http_for_test(&socket, "/health").await.unwrap(); + + assert_eq!(body.status, 200); + assert_eq!(body.body, "ok"); + server.abort(); +} +``` + +**Step 2: Run test to verify it fails** + +Run: + +```bash +cd server && cargo test --test daemon_gateway gateway_health_uses_unix_socket +``` + +Expected: FAIL because UDS forwarding does not exist. + +**Step 3: Implement UDS client** + +Use one of these implementation options: + +- Preferred if available: `reqwest` with its Unix socket feature. +- Fallback: `hyper-util` with `tokio::net::UnixStream`. + +Expose: + +```rust +pub async fn forward_http( + socket_path: &std::path::Path, + method: axum::http::Method, + daemon_path: String, + body: bytes::Bytes, +) -> Result +``` + +`DaemonResponse` should carry status, content type, and body bytes. + +**Step 4: Wire axum gateway route** + +In `server/src/main.rs`: + +- Route `/api/daemon/{*path}` for GET, POST, PATCH, DELETE. +- Normalize to daemon path. +- Forward request body. +- Return status and content type from daemon. +- Map daemon connection failure to `502 Bad Gateway`. + +Do not read or return `daemon.token`. + +**Step 5: Run tests** + +Run: + +```bash +cd server && cargo test --test daemon_gateway +cd server && cargo test +``` + +Expected: PASS. + +**Step 6: Commit** + +Run: + +```bash +git add server/Cargo.toml server/src/daemon_gateway.rs server/src/main.rs server/tests/daemon_gateway.rs +git commit -m "feat: proxy daemon http over unix socket" +``` + +### Task 13: Proxy Daemon WebSockets Over UDS + +**Files:** +- Modify: `server/src/daemon_gateway.rs` +- Modify: `server/src/main.rs` +- Create: `server/tests/daemon_gateway_ws.rs` +- Modify: `src/lib/daemon/stream.test.ts` + +**Step 1: Write failing frontend stream test** + +In `src/lib/daemon/stream.test.ts`, assert stream URLs are same-origin: + +```ts +it("opens the controller gateway stream route", () => { + const ws = vi.fn(); + vi.stubGlobal("WebSocket", ws as any); + daemonStore.client = new DaemonClient("/api/daemon"); + + const handle = openStream("s1"); + + expect(ws).toHaveBeenCalledWith("/api/daemon/sessions/s1/stream?since=0"); + handle.close(); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: + +```bash +pnpm test src/lib/daemon/stream.test.ts +``` + +Expected: FAIL until the client URL and gateway route are aligned. + +**Step 3: Write backend WS proxy test** + +Create `server/tests/daemon_gateway_ws.rs` with a focused test around gateway URL classification if full WS over UDS is too large for unit scope. The test must fail until the route recognizes stream paths: + +```rust +#[test] +fn stream_paths_are_websocket_gateway_paths() { + assert!(the_controller_lib::daemon_gateway::is_daemon_stream_path("/api/daemon/chats/c1/stream")); + assert!(the_controller_lib::daemon_gateway::is_daemon_stream_path("/api/daemon/sessions/s1/stream")); + assert!(!the_controller_lib::daemon_gateway::is_daemon_stream_path("/api/daemon/chats")); +} +``` + +**Step 4: Implement WS proxy** + +In `server/src/main.rs`, add GET routes for: + +```text +/api/daemon/chats/:id/stream +/api/daemon/sessions/:id/stream +``` + +The handler upgrades browser WS, opens a daemon WS over UDS, and relays frames both directions. + +If the first implementation cannot support WS over UDS with current dependencies, implement the explicit fallback route as server-sent events only after changing frontend `openStream` and tests to match. Do not use daemon TCP fallback. + +**Step 5: Run tests** + +Run: + +```bash +pnpm test src/lib/daemon/stream.test.ts +cd server && cargo test --test daemon_gateway_ws +cd server && cargo test +``` + +Expected: PASS. + +**Step 6: Commit** + +Run: + +```bash +git add src/lib/daemon/stream.test.ts server/src/daemon_gateway.rs server/src/main.rs server/tests/daemon_gateway_ws.rs +git commit -m "feat: proxy daemon streams through controller" +``` + +### Task 14: Add Controller Workspace Snapshot Commands + +**Files:** +- Modify: `server/src/models.rs` +- Modify: `server/src/storage.rs` +- Modify: `server/src/commands.rs` +- Modify: `server/src/main.rs` +- Create: `server/tests/chat_workspace_links.rs` + +**Step 1: Write failing backend test** + +Create `server/tests/chat_workspace_links.rs`: + +```rust +use the_controller_lib::models::ChatWorkspaceSnapshot; + +#[test] +fn workspace_snapshot_contains_controller_owned_fields_only() { + let snapshot = ChatWorkspaceSnapshot { + project_id: "p1".into(), + workspace_id: "w1".into(), + path: "/tmp/worktree".into(), + label: "feature-worktree".into(), + branch: Some("codex/feature".into()), + focused: true, + }; + + assert_eq!(snapshot.path, "/tmp/worktree"); +} +``` + +**Step 2: Run test to verify it fails** + +Run: + +```bash +cd server && cargo test --test chat_workspace_links +``` + +Expected: FAIL because the snapshot type does not exist. + +**Step 3: Implement snapshot model** + +In `server/src/models.rs`, add: + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatWorkspaceSnapshot { + pub project_id: String, + pub workspace_id: String, + pub path: String, + pub label: String, + pub branch: Option, + pub focused: bool, +} +``` + +**Step 4: Implement command helper** + +Add a pure helper in `commands.rs` that converts a `Project` and `SessionConfig` into `ChatWorkspaceSnapshot`. It must not call the daemon. The HTTP handler will post the resulting JSON to `/api/daemon/chats/:id/workspace-links`. + +**Step 5: Add route for workspace chat creation** + +Add a Controller route that: + +- creates or receives a chat id from the daemon gateway; +- creates any Controller-owned worktree using existing worktree conventions; +- posts the snapshot to the daemon; +- offloads filesystem/git work with `spawn_blocking`. + +**Step 6: Run tests** + +Run: + +```bash +cd server && cargo test --test chat_workspace_links +cd server && cargo test +``` + +Expected: PASS. + +**Step 7: Commit** + +Run: + +```bash +git add server/src/models.rs server/src/storage.rs server/src/commands.rs server/src/main.rs server/tests/chat_workspace_links.rs +git commit -m "feat: build controller workspace snapshots" +``` + +## Frontend Product Modes + +### Task 15: Expand Daemon Types And Store Around Profiles, Chats, Links, And Turns + +**Files:** +- Modify: `src/lib/daemon/types.ts` +- Modify: `src/lib/daemon/client.ts` +- Modify: `src/lib/daemon/store.svelte.ts` +- Modify: `src/lib/daemon/types.test.ts` +- Modify: `src/lib/daemon/client.test.ts` +- Modify: `src/lib/daemon/store.test.ts` + +**Step 1: Write failing type/client tests** + +In `src/lib/daemon/client.test.ts`, add: + +```ts +it("creates a chat through the gateway", async () => { + const fetchMock = mockFetch([{ status: 201, body: { id: "c1", project_id: "p1", title: "New chat" } }]); + vi.stubGlobal("fetch", fetchMock); + + const c = new DaemonClient("/api/daemon"); + const chat = await c.createChat({ project_id: "p1", title: "New chat" }); + + expect(chat.id).toBe("c1"); + expect(fetchMock.mock.calls[0][0]).toBe("/api/daemon/chats"); +}); +``` + +Add similar tests for `listProfiles`, `sendChatMessage`, and `getAgentTrace`. + +**Step 2: Run test to verify it fails** + +Run: + +```bash +pnpm test src/lib/daemon/client.test.ts +``` + +Expected: FAIL because methods/types do not exist. + +**Step 3: Add types** + +In `src/lib/daemon/types.ts`, add: + +- `AgentProfile` +- `AgentProfileVersion` +- `Chat` +- `ChatMessage` +- `ChatAgentLink` +- `ChatWorkspaceLink` +- `RouteToken` +- `SendChatMessageRequest` +- `AgentTurn` +- `AgentTurnTrace` +- `ChatMetrics` + +Keep old session event types until replaced by chat routes. + +**Step 4: Add client methods** + +Add methods: + +- `listProfiles` +- `saveProfile` +- `archiveProfile` +- `restoreProfile` +- `testProfileInChat` +- `listChats` +- `createChat` +- `deleteChat` +- `sendChatMessage` +- `readChatTranscript` +- `chatStreamUrl` +- `addWorkspaceLink` +- `focusWorkspaceLink` +- `getAgentTrace` +- `getChatMetrics` + +**Step 5: Update store state** + +Store should have separate maps for: + +- profiles; +- chats; +- active chat id; +- chat transcripts; +- chat summaries; +- agent traces; + +Do not remove session map until all existing components are migrated. + +**Step 6: Run tests** + +Run: + +```bash +pnpm test src/lib/daemon/types.test.ts src/lib/daemon/client.test.ts src/lib/daemon/store.test.ts +pnpm test +``` + +Expected: PASS. + +**Step 7: Commit** + +Run: + +```bash +git add src/lib/daemon/types.ts src/lib/daemon/client.ts src/lib/daemon/store.svelte.ts src/lib/daemon/*.test.ts +git commit -m "feat: model daemon chats and profiles in frontend" +``` + +### Task 16: Add Agent Creation Workspace Mode + +**Files:** +- Modify: `src/lib/stores.ts` +- Modify: `src/lib/WorkspaceModePicker.svelte` +- Modify: `src/App.svelte` +- Create: `src/lib/agents/AgentCreationWorkspace.svelte` +- Create: `src/lib/agents/ProfileList.svelte` +- Create: `src/lib/agents/ProfileEditor.svelte` +- Create: `src/lib/agents/ProfilePreviewDrawer.svelte` +- Create: `src/lib/agents/profile-validation.ts` +- Create: `src/lib/agents/AgentCreationWorkspace.test.svelte.ts` +- Create: `src/lib/agents/profile-validation.test.ts` + +**Step 1: Write failing validation test** + +Create `src/lib/agents/profile-validation.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { validateProfileDraft } from "./profile-validation"; + +describe("validateProfileDraft", () => { + it("blocks invalid handles and empty prompts", () => { + const result = validateProfileDraft({ + name: "Reviewer", + handle: "Reviewer!", + runtime: "codex", + prompt: "", + skills: [], + outbox_instructions: "", + default_workspace_behavior: "focused", + }); + + expect(result.blocking.map((x) => x.field)).toEqual(expect.arrayContaining(["handle", "prompt"])); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: + +```bash +pnpm test src/lib/agents/profile-validation.test.ts +``` + +Expected: FAIL because module does not exist. + +**Step 3: Implement validation helper** + +Create a pure validation helper matching PRD rules: + +- name required; +- handle required; +- handle uses lowercase letters, numbers, hyphens; +- runtime required; +- prompt required; +- missing skills/outbox/workspace behavior produce warnings, not blockers. + +**Step 4: Add workspace mode** + +In `stores.ts`, extend: + +```ts +export type WorkspaceMode = "agents" | "kanban" | "chat" | "agent-create" | "agent-observe"; +``` + +Update `WorkspaceModePicker.svelte` with compact entries for `Create` and `Observe`. Keep existing `Agents` mode until old maintainer/auto-worker dashboard is renamed. + +**Step 5: Build Agent Creation UI** + +Create: + +- profile list with search and active/archived filter; +- editor fields from PRD; +- preview drawer; +- explicit save; +- duplicate/archive/restore/test buttons; +- initials avatar fallback; +- dirty indicator. + +Use existing Catppuccin variables in `src/app.css`. Do not add nested cards. + +**Step 6: Write component test** + +In `AgentCreationWorkspace.test.svelte.ts`, verify: + +- empty state shows `No agent profiles`; +- `New Profile` opens editor draft; +- invalid handle disables save; +- valid save calls `daemonStore.client.saveProfile`. + +**Step 7: Run tests** + +Run: + +```bash +pnpm test src/lib/agents/profile-validation.test.ts src/lib/agents/AgentCreationWorkspace.test.svelte.ts +pnpm test +``` + +Expected: PASS. + +**Step 8: Commit** + +Run: + +```bash +git add src/lib/stores.ts src/lib/WorkspaceModePicker.svelte src/App.svelte src/lib/agents +git commit -m "feat: add agent creation workspace" +``` + +### Task 17: Implement Composer-First Chat Creation + +**Files:** +- Modify: `src/lib/chat/ChatWorkspace.svelte` +- Modify: `src/lib/chat/ChatView.svelte` +- Modify: `src/lib/chat/ChatInput.svelte` +- Modify: `src/lib/chat/ChatSessionList.svelte` +- Modify: `src/lib/HotkeyManager.svelte` +- Modify: `src/lib/Sidebar.svelte` +- Modify: `src/lib/chat/ChatWorkspace.test.ts` +- Modify: `src/lib/chat/ChatInput.test.ts` +- Create: `src/lib/chat/chat-routing.ts` +- Create: `src/lib/chat/chat-routing.test.ts` + +**Step 1: Write failing routing helper test** + +Create `src/lib/chat/chat-routing.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { extractRouteTokenQuery } from "./chat-routing"; + +describe("extractRouteTokenQuery", () => { + it("detects @ and % token queries at the cursor", () => { + expect(extractRouteTokenQuery("ask @rev", 8)).toEqual({ kind: "reusable", query: "rev", start: 4, end: 8 }); + expect(extractRouteTokenQuery("ask %debug", 10)).toEqual({ kind: "shadow", query: "debug", start: 4, end: 10 }); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: + +```bash +pnpm test src/lib/chat/chat-routing.test.ts +``` + +Expected: FAIL because helper does not exist. + +**Step 3: Implement token helper** + +Implement pure helpers for: + +- detecting current token query; +- inserting durable route token metadata; +- mapping selected profile to `RouteToken`. + +Do not rely only on post-submit string parsing. + +**Step 4: Write failing new-chat test** + +Update `ChatWorkspace.test.ts` or create a Svelte component test to verify `n` creates a chat without opening `NewChatDialog`. + +Expected behavior: + +- `daemonStore.client.createChat` called with focused project id. +- `daemonStore.activeChatId` set. +- composer receives focus. +- no agent selection modal appears. + +**Step 5: Implement composer-first flow** + +- Replace default modal path for `n` in chat mode. +- Keep advanced `NewChatDialog` only if reachable from a secondary action. +- Update `Esc` in composer to focus active chat row. +- Keep `Shift+Esc` interrupt behavior. + +**Step 6: Run tests** + +Run: + +```bash +pnpm test src/lib/chat/chat-routing.test.ts src/lib/chat/ChatWorkspace.test.ts src/lib/chat/ChatInput.test.ts +pnpm test +``` + +Expected: PASS. + +**Step 7: Commit** + +Run: + +```bash +git add src/lib/chat src/lib/HotkeyManager.svelte src/lib/Sidebar.svelte +git commit -m "feat: create chats from composer flow" +``` + +### Task 18: Add Agent Token Suggestions And Summary Pane + +**Files:** +- Modify: `src/lib/chat/ChatInput.svelte` +- Modify: `src/lib/chat/ChatView.svelte` +- Create: `src/lib/chat/AgentTokenMenu.svelte` +- Create: `src/lib/chat/ChatSummaryPane.svelte` +- Modify: `src/lib/chat/Transcript.svelte` +- Create: `src/lib/chat/AgentTokenMenu.test.svelte.ts` +- Create: `src/lib/chat/ChatSummaryPane.test.svelte.ts` +- Modify: `src/lib/chat/ChatInput.test.ts` + +**Step 1: Write failing token menu test** + +Create `AgentTokenMenu.test.svelte.ts`: + +```ts +import { render, fireEvent } from "@testing-library/svelte"; +import { describe, expect, it, vi } from "vitest"; +import AgentTokenMenu from "./AgentTokenMenu.svelte"; + +describe("AgentTokenMenu", () => { + it("labels reusable and shadow selections differently", async () => { + const onSelect = vi.fn(); + const { getByText } = render(AgentTokenMenu, { + kind: "shadow", + query: "rev", + profiles: [{ id: "p1", handle: "reviewer", name: "Reviewer", runtime: "codex", archived_at: null }], + onSelect, + }); + + await fireEvent.click(getByText("%reviewer")); + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ kind: "shadow", handle: "reviewer" })); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: + +```bash +pnpm test src/lib/chat/AgentTokenMenu.test.svelte.ts +``` + +Expected: FAIL because component does not exist. + +**Step 3: Implement menu** + +`AgentTokenMenu` should: + +- filter active profiles by handle/name; +- render `@handle` for reusable and `%handle` for shadow; +- support keyboard navigation; +- call `onSelect` with stable profile id and handle. + +**Step 4: Implement summary pane** + +`ChatSummaryPane` should show: + +- all linked agents; +- focused agent; +- all linked workspaces; +- focused workspace; +- quiet `none` state when empty. + +**Step 5: Integrate send path** + +`ChatInput` sends: + +```ts +await daemonStore.client.sendChatMessage(activeChatId, { + body: value, + tokens: routeTokens, + idempotency_id: crypto.randomUUID(), +}); +``` + +If no agent is associated and no token is present, show a local validation error instead of silently sending. + +**Step 6: Run tests** + +Run: + +```bash +pnpm test src/lib/chat/AgentTokenMenu.test.svelte.ts src/lib/chat/ChatSummaryPane.test.svelte.ts src/lib/chat/ChatInput.test.ts +pnpm test +``` + +Expected: PASS. + +**Step 7: Commit** + +Run: + +```bash +git add src/lib/chat +git commit -m "feat: add chat agent routing controls" +``` + +### Task 19: Add Chat Metrics Tab And Agent Observability Workspace + +**Files:** +- Modify: `src/lib/stores.ts` +- Modify: `src/App.svelte` +- Modify: `src/lib/chat/ChatView.svelte` +- Create: `src/lib/chat/ChatMetricsTab.svelte` +- Create: `src/lib/observability/AgentObservabilityWorkspace.svelte` +- Create: `src/lib/observability/AgentTraceView.svelte` +- Create: `src/lib/observability/turn-format.ts` +- Create: `src/lib/observability/turn-format.test.ts` +- Create: `src/lib/observability/AgentTraceView.test.svelte.ts` + +**Step 1: Write failing formatter test** + +Create `turn-format.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { formatMetricValue } from "./turn-format"; + +describe("formatMetricValue", () => { + it("distinguishes missing metrics from zero", () => { + expect(formatMetricValue(null, "tokens")).toBe("unavailable"); + expect(formatMetricValue(0, "tokens")).toBe("0 tokens"); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: + +```bash +pnpm test src/lib/observability/turn-format.test.ts +``` + +Expected: FAIL because module does not exist. + +**Step 3: Implement metrics tab** + +In `ChatView.svelte`: + +- add `Chat` and `Metrics` tabs; +- default to `Chat`; +- do not add a `Thinking` tab; +- load chat metrics from daemon client; +- link agent rows to `agent-observe` mode. + +**Step 4: Implement agent trace workspace** + +Create a turn-first page: + +- agent status/ownership header; +- linked chats/workspaces; +- current or most recent turn; +- reverse-chronological turn list; +- expandable details for thinking, tools, outbox, tokens, timing, errors. + +**Step 5: Run tests** + +Run: + +```bash +pnpm test src/lib/observability/turn-format.test.ts src/lib/observability/AgentTraceView.test.svelte.ts +pnpm test +``` + +Expected: PASS. + +**Step 6: Commit** + +Run: + +```bash +git add src/lib/stores.ts src/App.svelte src/lib/chat/ChatView.svelte src/lib/chat/ChatMetricsTab.svelte src/lib/observability +git commit -m "feat: add agent observability views" +``` + +## Avatar Jobs And Polish + +### Task 20: Add Background Avatar Job Scheduling In Controller + +**Files:** +- Modify: `server/src/models.rs` +- Create: `server/src/avatar_jobs.rs` +- Modify: `server/src/lib.rs` +- Modify: `server/src/main.rs` +- Create: `server/tests/avatar_jobs.rs` +- Modify: `src/lib/agents/ProfileEditor.svelte` + +**Step 1: Write failing avatar prompt test** + +Create `server/tests/avatar_jobs.rs`: + +```rust +use the_controller_lib::avatar_jobs::build_avatar_prompt; + +#[test] +fn avatar_prompt_requires_pixel_art_256_humanoid() { + let prompt = build_avatar_prompt("Reviewer", "Reviews code carefully"); + + assert!(prompt.contains("256 by 256")); + assert!(prompt.contains("pixel-art")); + assert!(prompt.contains("humanoid")); + assert!(prompt.contains("Reviewer")); +} +``` + +**Step 2: Run test to verify it fails** + +Run: + +```bash +cd server && cargo test --test avatar_jobs +``` + +Expected: FAIL because `avatar_jobs` does not exist. + +**Step 3: Implement job module** + +Add: + +- `build_avatar_prompt(name, description)`; +- `schedule_avatar_generation(profile_id, profile_name, description)`; +- a background task that runs Codex CLI with the image generation request; +- result storage path under Controller state, such as `~/.the-controller/assets/agent-avatars/.png`; +- success/failure callback to daemon `/profiles/:id/avatar` or direct gateway route if added. + +Do not block profile save or `Test in Chat`. + +**Step 4: Add retry UI** + +In `ProfileEditor.svelte`, show initials avatar while pending/failed and expose retry for saved profiles. + +**Step 5: Run tests** + +Run: + +```bash +cd server && cargo test --test avatar_jobs +cd server && cargo test +pnpm test src/lib/agents/AgentCreationWorkspace.test.svelte.ts +``` + +Expected: PASS. + +**Step 6: Commit** + +Run: + +```bash +git add server/src/models.rs server/src/avatar_jobs.rs server/src/lib.rs server/src/main.rs server/tests/avatar_jobs.rs src/lib/agents/ProfileEditor.svelte +git commit -m "feat: schedule agent avatar generation" +``` + +## End-To-End Validation + +### Task 21: Add End-To-End Agent Routing Evaluation + +**Files:** +- Create: `e2e/specs/agent-routing.spec.ts` +- Modify: `playwright.config.ts` only if needed. +- Modify: `e2e/eval.sh` only if needed. + +**Step 1: Write failing Playwright test** + +Create `e2e/specs/agent-routing.spec.ts`: + +```ts +import { test, expect } from "@playwright/test"; + +test("creates profile, routes chat message, and shows outbox reply only", async ({ page }) => { + await page.goto("/"); + await page.keyboard.press("Meta+k"); + await page.keyboard.press("c"); + + await page.getByRole("button", { name: "New Profile" }).click(); + await page.getByLabel("Name").fill("Reviewer"); + await page.getByLabel("Handle").fill("reviewer"); + await page.getByLabel("Prompt").fill("Review carefully."); + await page.getByRole("button", { name: "Save" }).click(); + + await page.keyboard.press("Meta+k"); + await page.keyboard.press("c"); + await page.keyboard.press("n"); + await page.getByRole("textbox", { name: "Chat input" }).fill("Please review @reviewer"); + await page.keyboard.press("Meta+Enter"); + + await expect(page.getByText("@reviewer")).toBeVisible(); + await expect(page.getByText("raw stdout")).toHaveCount(0); +}); +``` + +Adjust selectors to actual labels after UI implementation. + +**Step 2: Run test to verify it fails or is skipped for missing daemon harness** + +Run: + +```bash +pnpm exec playwright test e2e/specs/agent-routing.spec.ts +``` + +Expected before harness work: FAIL because daemon/test setup is incomplete. + +**Step 3: Add daemon harness** + +Update `e2e/eval.sh` or Playwright setup to: + +- start `the-controller-daemon` with `TCD_STATE_DIR` pointing at a temp dir; +- set `TCD_AGENT_CODEX_BINARY` to the daemon fake agent; +- start Controller via `./dev.sh` or equivalent; +- clean up processes and temp dirs. + +Do not start daemon TCP. + +**Step 4: Run E2E** + +Run: + +```bash +pnpm exec playwright test e2e/specs/agent-routing.spec.ts +``` + +Expected: PASS. + +**Step 5: Commit** + +Run: + +```bash +git add e2e/specs/agent-routing.spec.ts e2e/eval.sh playwright.config.ts +git commit -m "test: cover agent routing end to end" +``` + +### Task 22: Final Architecture Verification Checklist + +**Files:** +- Modify: tests only if gaps are found. + +**Step 1: Run daemon full test suite** + +Run from daemon repo: + +```bash +cargo test +``` + +Expected: PASS. + +**Step 2: Run Controller backend tests** + +Run from Controller repo: + +```bash +cd server && cargo test +``` + +Expected: PASS. + +**Step 3: Run frontend tests** + +Run from Controller repo: + +```bash +pnpm test +``` + +Expected: PASS. + +**Step 4: Run E2E** + +Run from Controller repo: + +```bash +pnpm exec playwright test e2e/specs/agent-routing.spec.ts +``` + +Expected: PASS. + +**Step 5: Verify architecture requirements** + +Manually check each item against tests or code: + +- Controller restart does not stop daemon sessions. +- Daemon restart preserves chats, links, sessions, events, turns, and pending inbox items. +- Browser code calls only same-origin Controller routes. +- Controller backend talks to the daemon over a Unix socket. +- `@agent` links reusable sessions that survive chat deletion. +- `%agent` creates shadow sessions on send and stops them on chat deletion. +- One chat send fans out to one inbox item and one turn per linked agent. +- Chat transcript rendering excludes raw runtime output. +- Observability can replay by chat, agent session, and turn. + +If any item lacks a test, add one before claiming completion. + +**Step 6: Commit final fixes** + +Run: + +```bash +git status --short +git add +git commit -m "test: verify agent architecture" +``` + +Only commit if there are changes. + +## Execution Checkpoints + +Report for review after each batch: + +1. Tasks 1-4: transport and browser-safe gateway. +2. Tasks 5-7: daemon durable model. +3. Tasks 8-11: chat routing API. +4. Tasks 12-14: Controller gateway and workspace snapshots. +5. Tasks 15-19: frontend product modes. +6. Tasks 20-22: avatar jobs and end-to-end validation. + +Each report must include: + +- commits created in each repo; +- tests run with exact commands and pass/fail status; +- any architecture requirement still untested; +- any changes needed to this plan before the next batch. + diff --git a/e2e/specs/chat-mode.spec.ts b/e2e/specs/chat-mode.spec.ts index c878f62..31c4087 100644 --- a/e2e/specs/chat-mode.spec.ts +++ b/e2e/specs/chat-mode.spec.ts @@ -1,8 +1,7 @@ import { test, expect } from "@playwright/test"; -import { existsSync, mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { existsSync } from "node:fs"; import { spawn, type ChildProcess } from "node:child_process"; +import { isAbsolute } from "node:path"; // User story: // Actor: A user wanting to use the new "chat" workspace mode backed by the @@ -13,11 +12,12 @@ import { spawn, type ChildProcess } from "node:child_process"; // Outcome: Empty state is shown when daemon is unreachable. After the daemon // is started, retrying transitions out of the empty state. // -// NOTE: The daemon-reachable leg of this test is skipped by default because -// it requires the daemon & fake_agent binaries to be built at the canonical -// paths below. The axum server (server/src/main.rs) exposes -// `/api/read_daemon_token` so the browser harness can load the daemon token -// when the daemon is running. +// NOTE: The daemon-reachable leg is skipped by default. It may run only when +// the daemon binaries exist and the caller opts into a shared harness with +// TCD_E2E_DAEMON_REACHABLE=1, THE_CONTROLLER_STATE_DIR=, +// and TCD_STATE_DIR=. Playwright starts the +// Controller before this spec runs, so the Controller and daemon must inherit +// the same absolute state dir for `/api/daemon/...` to target the same UDS. const DAEMON_REPO = "/Users/noelkwan/projects/the-controller-daemon"; const DAEMON_BIN = `${DAEMON_REPO}/target/release/the-controller-daemon`; @@ -39,8 +39,8 @@ test("chat mode shows DaemonEmptyState when daemon is unreachable", async ({ pag await switchToChatMode(page); // Core assertion: the DaemonEmptyState component renders its heading when - // the daemon cannot be reached. The Axum server exposes `read_daemon_token`, - // but this test does not start the daemon, so bootstrap fails deterministically. + // the daemon cannot be reached. This test does not start the daemon, so + // bootstrap fails deterministically through the same-origin gateway. await expect(page.getByRole("heading", { name: "Daemon not running" })).toBeVisible({ timeout: 5_000, }); @@ -58,26 +58,38 @@ test("chat mode shows DaemonEmptyState when daemon is unreachable", async ({ pag }); // --------------------------------------------------------------------------- -// Daemon-reachable leg — SKIPPED unless the daemon binaries are built. +// Daemon-reachable leg — SKIPPED unless an explicit shared harness is present. // --------------------------------------------------------------------------- const daemonBinariesPresent = existsSync(DAEMON_BIN) && existsSync(FAKE_AGENT_BIN); +const daemonReachableHarnessEnabled = process.env.TCD_E2E_DAEMON_REACHABLE === "1"; +const controllerStateDir = process.env.THE_CONTROLLER_STATE_DIR; +const daemonStateDir = process.env.TCD_STATE_DIR; +const sharedStateDir = + controllerStateDir && + daemonStateDir && + isAbsolute(controllerStateDir) && + isAbsolute(daemonStateDir) && + controllerStateDir === daemonStateDir + ? daemonStateDir + : undefined; +const daemonReachableEnabled = + daemonBinariesPresent && daemonReachableHarnessEnabled && Boolean(sharedStateDir); test.describe("chat mode with daemon reachable", () => { test.skip( - !daemonBinariesPresent, - `daemon binaries not built at ${DAEMON_BIN} / ${FAKE_AGENT_BIN}. ` + - "Run scripts/chat-integration-daemon.sh build to produce them.", + !daemonReachableEnabled, + "daemon reachable e2e requires binaries plus " + + "TCD_E2E_DAEMON_REACHABLE=1 with matching absolute THE_CONTROLLER_STATE_DIR and TCD_STATE_DIR. " + + "Task 21 owns the full daemon harness.", ); let daemon: ChildProcess | null = null; - let stateDir: string | null = null; test.beforeAll(async () => { - stateDir = mkdtempSync(join(tmpdir(), "tcd-e2e-")); daemon = spawn(DAEMON_BIN, [], { env: { ...process.env, - TCD_STATE_DIR: stateDir, + TCD_STATE_DIR: sharedStateDir, TCD_AGENT_CLAUDE_BINARY: FAKE_AGENT_BIN, }, stdio: "pipe", @@ -92,13 +104,6 @@ test.describe("chat mode with daemon reachable", () => { await new Promise((r) => setTimeout(r, 300)); if (!daemon.killed) daemon.kill("SIGKILL"); } - if (stateDir) { - try { - rmSync(stateDir, { recursive: true, force: true }); - } catch { - // best effort - } - } }); test("workspace renders out of the empty state after Retry", async ({ page }) => { diff --git a/e2e/specs/observability.spec.ts b/e2e/specs/observability.spec.ts new file mode 100644 index 0000000..b42cb0f --- /dev/null +++ b/e2e/specs/observability.spec.ts @@ -0,0 +1,22 @@ +import { test, expect } from "@playwright/test"; + +// User story: +// Actor: A developer navigating workspace modes from the keyboard. +// Action: Opens the workspace picker and chooses Observe. +// Outcome: The agent trace workspace appears with a clear empty state. + +test("observe mode opens the agent trace workspace", async ({ page }) => { + await page.goto("/"); + await expect(page.locator(".sidebar")).toBeVisible({ timeout: 10_000 }); + + await page.keyboard.press("Space"); + await expect(page.locator(".picker")).toBeVisible({ timeout: 3_000 }); + await page.keyboard.press("o"); + + await expect(page.locator(".sidebar-header h2")).toHaveText("Observe", { + timeout: 3_000, + }); + await expect(page.getByText("Select an agent to inspect.")).toBeVisible({ + timeout: 10_000, + }); +}); diff --git a/server/Cargo.lock b/server/Cargo.lock index a967241..38a279a 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1806,7 +1806,7 @@ dependencies = [ "serde_json", "tempfile", "tokio", - "tower-http", + "tokio-tungstenite", "uuid", ] diff --git a/server/Cargo.toml b/server/Cargo.toml index 74c4ff5..2e5140d 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -25,7 +25,7 @@ libc = "0.2" reqwest = { version = "0.12", features = ["json", "stream"] } futures-util = "0.3" axum = { version = "0.8", features = ["ws"] } -tower-http = { version = "0.6", features = ["cors"] } +tokio-tungstenite = "0.28" [[bin]] name = "the-controller-server" diff --git a/server/src/commands.rs b/server/src/commands.rs index 9f59f3c..b3de9df 100644 --- a/server/src/commands.rs +++ b/server/src/commands.rs @@ -3,12 +3,13 @@ use std::path::{Path, PathBuf}; use uuid::Uuid; use crate::config; -use crate::models::{AutoWorkerQueueIssue, CommitInfo, GithubIssue, Project, SessionConfig}; +use crate::models::{ + AutoWorkerQueueIssue, ChatWorkspaceSnapshot, CommitInfo, GithubIssue, Project, SessionConfig, +}; use crate::state::AppState; use crate::token_usage::{self, TokenDataPoint}; use crate::worktree::WorktreeManager; -pub mod daemon; pub mod github; pub mod kanban; @@ -52,6 +53,61 @@ pub(crate) fn next_session_label(sessions: &[SessionConfig]) -> String { format!("session-{}-{}", max_num + 1, short_id) } +pub fn chat_workspace_snapshot( + project: &Project, + session: &SessionConfig, + focused: bool, +) -> ChatWorkspaceSnapshot { + ChatWorkspaceSnapshot { + project_id: project.id.to_string(), + workspace_id: session.id.to_string(), + path: session + .worktree_path + .clone() + .unwrap_or_else(|| project.repo_path.clone()), + label: session.label.clone(), + branch: session.worktree_branch.clone(), + focused, + } +} + +pub fn daemon_chat_cleanup_path(chat_id: &str, created_by_handler: bool) -> Option { + created_by_handler.then(|| format!("/api/daemon/chats/{chat_id}")) +} + +pub fn validate_chat_workspace_project_impl( + state: &AppState, + project_id: &str, +) -> Result<(), String> { + let project_uuid = Uuid::parse_str(project_id).map_err(|e| e.to_string())?; + let storage = state.storage.lock().map_err(|e| e.to_string())?; + storage + .load_project(project_uuid) + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +pub fn load_chat_workspace_snapshot_impl( + state: &AppState, + project_id: &str, + session_id: &str, + focused: bool, +) -> Result { + let project_uuid = Uuid::parse_str(project_id).map_err(|e| e.to_string())?; + let session_uuid = Uuid::parse_str(session_id).map_err(|e| e.to_string())?; + let storage = state.storage.lock().map_err(|e| e.to_string())?; + let project = storage + .load_project(project_uuid) + .map_err(|e| e.to_string())?; + let session = project + .sessions + .iter() + .find(|session| session.id == session_uuid) + .ok_or_else(|| format!("session not found: {session_id}"))?; + + Ok(chat_workspace_snapshot(&project, session, focused)) +} + fn update_project_with_rollback( state: &AppState, project_id: Uuid, @@ -587,6 +643,34 @@ pub fn create_session_impl( Ok(session_id.to_string()) } +#[allow(clippy::too_many_arguments)] +pub fn create_session_workspace_snapshot_impl( + state: &AppState, + project_id: String, + kind: Option, + github_issue: Option, + background: Option, + initial_prompt: Option, + focused: bool, +) -> Result<(String, ChatWorkspaceSnapshot), String> { + let session_id = create_session_impl( + state, + project_id.clone(), + kind, + github_issue, + background, + initial_prompt, + )?; + + match load_chat_workspace_snapshot_impl(state, &project_id, &session_id, focused) { + Ok(snapshot) => Ok((session_id, snapshot)), + Err(snapshot_err) => match close_session_impl(state, project_id, session_id, true) { + Ok(()) => Err(snapshot_err), + Err(cleanup_err) => Err(format!("{snapshot_err} (cleanup failed: {cleanup_err})")), + }, + } +} + pub fn set_initial_prompt_impl( state: &AppState, project_id: String, diff --git a/server/src/commands/daemon.rs b/server/src/commands/daemon.rs deleted file mode 100644 index 57b583d..0000000 --- a/server/src/commands/daemon.rs +++ /dev/null @@ -1,48 +0,0 @@ -use std::path::PathBuf; - -pub(crate) fn daemon_token_path() -> PathBuf { - if let Ok(dir) = std::env::var("TCD_STATE_DIR") { - return PathBuf::from(dir).join("daemon.token"); - } - let home = std::env::var("HOME").unwrap_or_default(); - PathBuf::from(home) - .join(".the-controller") - .join("daemon.token") -} - -pub(crate) fn read_token_from(path: &std::path::Path) -> Result { - let bytes = std::fs::read(path) - .map_err(|e| format!("read daemon token at {}: {}", path.display(), e))?; - let s = String::from_utf8(bytes).map_err(|e| format!("token not utf-8: {e}"))?; - Ok(s.trim().to_string()) -} - -pub async fn read_daemon_token() -> Result { - let path = daemon_token_path(); - tokio::task::spawn_blocking(move || read_token_from(&path)) - .await - .map_err(|e| format!("join error: {e}"))? -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Write; - - #[test] - fn reads_and_trims_token() { - let dir = tempfile::tempdir().unwrap(); - let p = dir.path().join("daemon.token"); - let mut f = std::fs::File::create(&p).unwrap(); - writeln!(f, "abc123").unwrap(); - assert_eq!(read_token_from(&p).unwrap(), "abc123"); - } - - #[test] - fn missing_token_returns_err() { - let dir = tempfile::tempdir().unwrap(); - let p = dir.path().join("daemon.token"); - let err = read_token_from(&p).unwrap_err(); - assert!(err.contains("daemon.token"), "got: {}", err); - } -} diff --git a/server/src/daemon_gateway.rs b/server/src/daemon_gateway.rs new file mode 100644 index 0000000..529c050 --- /dev/null +++ b/server/src/daemon_gateway.rs @@ -0,0 +1,247 @@ +use std::path::{Path, PathBuf}; + +use axum::{ + body::Bytes, + http::{Method, StatusCode}, +}; +use tokio::net::UnixStream; +use tokio_tungstenite::{client_async, WebSocketStream}; + +#[derive(Debug, Clone)] +pub struct DaemonGatewayConfig { + pub state_dir: PathBuf, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DaemonResponse { + pub status: u16, + pub content_type: Option, + pub body: Bytes, +} + +pub fn daemon_socket_path(cfg: &DaemonGatewayConfig) -> PathBuf { + cfg.state_dir.join("daemon.sock") +} + +pub fn normalize_daemon_path(path: &str) -> Result { + let rest = path + .strip_prefix("/api/daemon") + .ok_or_else(|| "path must start with /api/daemon".to_string())?; + if !rest.is_empty() && !rest.starts_with('/') { + return Err("path must stay under /api/daemon".to_string()); + } + let decoded_rest = percent_decode(rest)?; + if decoded_rest.contains("..") { + return Err("daemon gateway path must not contain ..".to_string()); + } + Ok(if rest.is_empty() { + "/".to_string() + } else { + rest.to_string() + }) +} + +pub fn is_daemon_stream_path(path: &str) -> bool { + let Ok(daemon_path) = normalize_daemon_path(path) else { + return false; + }; + let segments: Vec<_> = daemon_path.trim_start_matches('/').split('/').collect(); + + matches!( + segments.as_slice(), + ["chats", id, "stream"] | ["sessions", id, "stream"] if !id.is_empty() + ) +} + +pub fn is_allowed_daemon_stream_origin(origin: Option<&str>, host: Option<&str>) -> bool { + let Some(origin) = origin else { + return true; + }; + let Some(request_authority) = host.and_then(parse_authority) else { + return false; + }; + let Some(origin) = parse_origin(origin) else { + return false; + }; + + origin.authority == request_authority && is_trusted_daemon_stream_host(&request_authority.host) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct OriginParts { + authority: AuthorityParts, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct AuthorityParts { + host: String, + port: Option, +} + +fn parse_origin(origin: &str) -> Option { + let (scheme, authority) = origin.split_once("://")?; + if !matches!(scheme, "http" | "https") { + return None; + } + if authority.is_empty() + || authority.contains('/') + || authority.contains('?') + || authority.contains('#') + { + return None; + } + + Some(OriginParts { + authority: parse_authority(authority)?, + }) +} + +fn parse_authority(authority: &str) -> Option { + let authority: axum::http::uri::Authority = authority.parse().ok()?; + + Some(AuthorityParts { + host: authority.host().to_ascii_lowercase(), + port: authority.port_u16(), + }) +} + +fn is_trusted_daemon_stream_host(host: &str) -> bool { + matches!(host, "localhost" | "127.0.0.1" | "::1" | "[::1]") +} + +pub async fn connect_daemon_websocket( + socket_path: &Path, + daemon_path: &str, +) -> Result, String> { + let stream = UnixStream::connect(socket_path) + .await + .map_err(|e| format!("connect to daemon websocket: {e}"))?; + let url = format!("ws://daemon.local{daemon_path}"); + let (ws, _response) = client_async(url, stream) + .await + .map_err(|e| format!("open daemon websocket: {e}"))?; + + Ok(ws) +} + +pub async fn forward_http( + socket_path: &Path, + method: Method, + daemon_path: String, + body: Bytes, +) -> Result { + forward_http_with_content_type(socket_path, method, daemon_path, body, None).await +} + +pub async fn forward_http_with_content_type( + socket_path: &Path, + method: Method, + daemon_path: String, + body: Bytes, + content_type: Option, +) -> Result { + let method = reqwest::Method::from_bytes(method.as_str().as_bytes()) + .map_err(|e| format!("invalid daemon gateway method: {e}"))?; + let url = format!("http://daemon.local{daemon_path}"); + let client = reqwest::Client::builder() + .unix_socket(socket_path) + .build() + .map_err(|e| format!("build daemon gateway client: {e}"))?; + let mut request = client.request(method, url).body(body); + if let Some(content_type) = content_type { + request = request.header(reqwest::header::CONTENT_TYPE, content_type); + } + let response = request + .send() + .await + .map_err(|e| format!("connect to daemon gateway: {e}"))?; + let status = response.status().as_u16(); + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(ToOwned::to_owned); + let body = response + .bytes() + .await + .map_err(|e| format!("read daemon gateway response: {e}"))?; + + Ok(DaemonResponse { + status, + content_type, + body, + }) +} + +pub async fn proxy_http_gateway( + socket_path: &Path, + method: Method, + original_path: &str, + query: Option<&str>, + body: Bytes, + content_type: Option, +) -> Result { + let mut daemon_path = + normalize_daemon_path(original_path).map_err(|e| (StatusCode::BAD_REQUEST, e))?; + if let Some(query) = query { + daemon_path.push('?'); + daemon_path.push_str(query); + } + + forward_http_with_content_type(socket_path, method, daemon_path, body, content_type) + .await + .map_err(|_| { + ( + StatusCode::BAD_GATEWAY, + "daemon gateway unavailable".to_string(), + ) + }) +} + +pub async fn forward_http_for_test( + socket_path: &Path, + daemon_path: &str, +) -> Result { + forward_http( + socket_path, + Method::GET, + daemon_path.to_string(), + Bytes::new(), + ) + .await +} + +fn percent_decode(input: &str) -> Result { + let bytes = input.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + + while index < bytes.len() { + if bytes[index] == b'%' { + if index + 2 >= bytes.len() { + return Err("daemon gateway path contains invalid percent escape".to_string()); + } + let high = hex_value(bytes[index + 1]) + .ok_or_else(|| "daemon gateway path contains invalid percent escape".to_string())?; + let low = hex_value(bytes[index + 2]) + .ok_or_else(|| "daemon gateway path contains invalid percent escape".to_string())?; + decoded.push((high << 4) | low); + index += 3; + } else { + decoded.push(bytes[index]); + index += 1; + } + } + + String::from_utf8(decoded) + .map_err(|_| "daemon gateway path contains invalid utf-8 escape".to_string()) +} + +fn hex_value(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} diff --git a/server/src/lib.rs b/server/src/lib.rs index 8615b8a..3334fe0 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -2,6 +2,7 @@ pub mod auto_worker; pub mod cli_install; pub mod commands; pub mod config; +pub mod daemon_gateway; pub mod emitter; pub mod labels; pub mod maintainer; diff --git a/server/src/main.rs b/server/src/main.rs index 46b5f15..c5f85c9 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,22 +1,37 @@ use axum::{ + body::Bytes, extract::{ ws::{Message, WebSocket}, - State as AxumState, WebSocketUpgrade, + OriginalUri, State as AxumState, WebSocketUpgrade, }, - http::StatusCode, - response::IntoResponse, + http::{header, HeaderMap, HeaderValue, Method, StatusCode}, + response::{IntoResponse, Response}, routing::{get, post}, Json, Router, }; use serde_json::Value; -use std::sync::Arc; +use std::{path::Path, sync::Arc}; use the_controller_lib::{ - auto_worker::AutoWorkerScheduler, cli_install, commands, config, emitter::WsBroadcastEmitter, - maintainer::MaintainerScheduler, shell_env, skills, state::AppState, status_socket, + auto_worker::AutoWorkerScheduler, + cli_install, commands, config, + daemon_gateway::{ + connect_daemon_websocket, daemon_socket_path, is_allowed_daemon_stream_origin, + is_daemon_stream_path, normalize_daemon_path, proxy_http_gateway, DaemonGatewayConfig, + }, + emitter::WsBroadcastEmitter, + maintainer::MaintainerScheduler, + models::{ChatWorkspaceSnapshot, GithubIssue}, + shell_env, skills, + state::AppState, + status_socket, }; +use futures_util::{SinkExt, StreamExt}; use tokio::sync::broadcast; -use tower_http::cors::CorsLayer; +use tokio_tungstenite::tungstenite::{ + protocol::{frame::coding::CloseCode, CloseFrame as TungsteniteCloseFrame}, + Message as TungsteniteMessage, +}; struct ServerState { app: Arc, @@ -56,9 +71,21 @@ async fn main() { .route("/api/resize_pty", post(resize_pty)) .route("/api/close_session", post(close_session)) .route("/api/create_session", post(create_session)) + .route("/api/create_chat_workspace", post(create_chat_workspace)) .route("/api/list_archived_projects", post(list_archived_projects)) .route("/api/merge_session_branch", post(merge_session_branch)) - .route("/api/read_daemon_token", post(read_daemon_token)) + .route("/api/daemon/chats/{id}/stream", get(daemon_stream_gateway)) + .route( + "/api/daemon/sessions/{id}/stream", + get(daemon_stream_gateway), + ) + .route( + "/api/daemon/{*path}", + get(daemon_gateway) + .post(daemon_gateway) + .patch(daemon_gateway) + .delete(daemon_gateway), + ) .route("/api/log_frontend_error", post(log_frontend_error)) .route("/api/get_agents_md", post(get_agents_md)) .route("/api/update_agents_md", post(update_agents_md)) @@ -122,7 +149,6 @@ async fn main() { .route("/api/unstage_session", post(unstage_session)) .route("/ws", get(ws_upgrade)) .fallback(fallback_handler) - .layer(CorsLayer::permissive()) .with_state(state); let port: u16 = match std::env::var("PORT") { @@ -475,11 +501,460 @@ async fn create_session( Ok(Json(Value::String(session_id))) } -async fn read_daemon_token() -> Result, (StatusCode, String)> { - let token = commands::daemon::read_daemon_token() +async fn create_chat_workspace( + AxumState(state): AxumState>, + Json(args): Json, +) -> Result, (StatusCode, String)> { + let project_id = args["projectId"] + .as_str() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "projectId required".to_string()))? + .to_string(); + let kind = args["kind"].as_str().map(str::to_string); + let background = args["background"].as_bool(); + let initial_prompt = args["initialPrompt"].as_str().map(str::to_string); + let github_issue: Option = if args["githubIssue"].is_null() { + None + } else { + Some( + serde_json::from_value(args["githubIssue"].clone()) + .map_err(|e| (StatusCode::BAD_REQUEST, format!("githubIssue: {e}")))?, + ) + }; + + let state_dir = state + .app + .storage + .lock() + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .base_dir(); + let socket_path = daemon_socket_path(&DaemonGatewayConfig { state_dir }); + + let validation_state = state.clone(); + let validation_project_id = project_id.clone(); + tokio::task::spawn_blocking(move || { + commands::validate_chat_workspace_project_impl( + &validation_state.app, + &validation_project_id, + ) + }) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Task failed: {e}"), + ) + })? + .map_err(|e| (StatusCode::BAD_REQUEST, e))?; + + let (chat_id, created_chat_cleanup) = match args["chatId"].as_str() { + Some(chat_id) => { + validate_daemon_chat_id(chat_id)?; + (chat_id.to_string(), None) + } + None => { + let title = args["title"].as_str().unwrap_or("New chat"); + let chat_id = create_daemon_chat(&socket_path, &project_id, title).await?; + let cleanup = commands::daemon_chat_cleanup_path(&chat_id, true); + (chat_id, cleanup) + } + }; + + let blocking_state = state.clone(); + let blocking_project_id = project_id.clone(); + let session_result = tokio::task::spawn_blocking(move || { + commands::create_session_workspace_snapshot_impl( + &blocking_state.app, + blocking_project_id, + kind, + github_issue, + background, + initial_prompt, + true, + ) + }) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Task failed: {e}"), + ) + }) + .and_then(|result| result.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))); + let (session_id, workspace) = match session_result { + Ok(result) => result, + Err((status, message)) => { + let daemon_cleanup_error = + cleanup_daemon_chat(&socket_path, created_chat_cleanup.as_deref()).await; + return Err((status, append_cleanup_error(message, daemon_cleanup_error))); + } + }; + + let link = match post_daemon_workspace_link(&socket_path, &chat_id, &workspace).await { + Ok(link) => link, + Err((status, message)) => { + let cleanup_error = + cleanup_chat_workspace_session(state, project_id, session_id.clone()).await; + let message = append_cleanup_error(message, cleanup_error); + let daemon_cleanup_error = + cleanup_daemon_chat(&socket_path, created_chat_cleanup.as_deref()).await; + return Err((status, append_cleanup_error(message, daemon_cleanup_error))); + } + }; + + Ok(Json(serde_json::json!({ + "chatId": chat_id, + "sessionId": session_id, + "workspace": workspace, + "link": link + }))) +} + +async fn create_daemon_chat( + socket_path: &Path, + project_id: &str, + title: &str, +) -> Result { + let body = json_bytes(&serde_json::json!({ + "project_id": project_id, + "title": title + }))?; + let response = proxy_http_gateway( + socket_path, + Method::POST, + "/api/daemon/chats", + None, + body, + Some("application/json".to_string()), + ) + .await?; + let status = StatusCode::from_u16(response.status) + .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?; + if !status.is_success() { + return Err(( + status, + daemon_response_error("daemon chat creation failed", &response.body), + )); + } + + let chat: Value = serde_json::from_slice(&response.body).map_err(|e| { + ( + StatusCode::BAD_GATEWAY, + format!("daemon chat response was not valid JSON: {e}"), + ) + })?; + let chat_id = chat["id"].as_str().ok_or_else(|| { + ( + StatusCode::BAD_GATEWAY, + "daemon chat response missing id".to_string(), + ) + })?; + validate_daemon_chat_id(chat_id)?; + + Ok(chat_id.to_string()) +} + +async fn post_daemon_workspace_link( + socket_path: &Path, + chat_id: &str, + workspace: &ChatWorkspaceSnapshot, +) -> Result { + validate_daemon_chat_id(chat_id)?; + let body = json_bytes(workspace)?; + let path = format!("/api/daemon/chats/{chat_id}/workspace-links"); + let response = proxy_http_gateway( + socket_path, + Method::POST, + &path, + None, + body, + Some("application/json".to_string()), + ) + .await?; + let status = StatusCode::from_u16(response.status) + .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?; + if !status.is_success() { + return Err(( + status, + daemon_response_error("daemon workspace link failed", &response.body), + )); + } + + serde_json::from_slice(&response.body).map_err(|e| { + ( + StatusCode::BAD_GATEWAY, + format!("daemon workspace link response was not valid JSON: {e}"), + ) + }) +} + +fn validate_daemon_chat_id(chat_id: &str) -> Result<(), (StatusCode, String)> { + if chat_id.is_empty() || chat_id.contains('/') || chat_id.contains('?') || chat_id.contains('#') + { + return Err(( + StatusCode::BAD_REQUEST, + "chatId must be a daemon chat id".to_string(), + )); + } + Ok(()) +} + +fn json_bytes(value: &T) -> Result { + serde_json::to_vec(value) + .map(Bytes::from) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())) +} + +fn daemon_response_error(prefix: &str, body: &Bytes) -> String { + let body = String::from_utf8_lossy(body).trim().to_string(); + if body.is_empty() { + prefix.to_string() + } else { + format!("{prefix}: {body}") + } +} + +async fn cleanup_chat_workspace_session( + state: Arc, + project_id: String, + session_id: String, +) -> Option { + match tokio::task::spawn_blocking(move || { + commands::close_session_impl(&state.app, project_id, session_id, true) + }) + .await + { + Ok(Ok(())) => None, + Ok(Err(e)) => Some(e), + Err(e) => Some(format!("cleanup task failed: {e}")), + } +} + +async fn cleanup_daemon_chat(socket_path: &Path, cleanup_path: Option<&str>) -> Option { + let cleanup_path = cleanup_path?; + match proxy_http_gateway( + socket_path, + Method::DELETE, + cleanup_path, + None, + Bytes::new(), + None, + ) + .await + { + Ok(response) => { + let Ok(status) = StatusCode::from_u16(response.status) else { + return Some(format!("daemon chat cleanup returned {}", response.status)); + }; + if status.is_success() { + None + } else { + Some(daemon_response_error( + "daemon chat cleanup failed", + &response.body, + )) + } + } + Err((status, message)) => Some(format!("daemon chat cleanup failed ({status}): {message}")), + } +} + +fn append_cleanup_error(message: String, cleanup_error: Option) -> String { + match cleanup_error { + Some(cleanup_error) => format!("{message} (cleanup failed: {cleanup_error})"), + None => message, + } +} + +async fn daemon_gateway( + AxumState(state): AxumState>, + OriginalUri(uri): OriginalUri, + method: Method, + headers: HeaderMap, + body: Bytes, +) -> Result { + let state_dir = state + .app + .storage + .lock() + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .base_dir(); + let socket_path = daemon_socket_path(&DaemonGatewayConfig { state_dir }); + let content_type = headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(ToOwned::to_owned); + let daemon_response = proxy_http_gateway( + &socket_path, + method, + uri.path(), + uri.query(), + body, + content_type, + ) + .await?; + + let status = StatusCode::from_u16(daemon_response.status) + .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?; + let mut response = Response::builder().status(status); + if let Some(content_type) = daemon_response.content_type { + let value = HeaderValue::from_str(&content_type) + .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))?; + response = response.header(header::CONTENT_TYPE, value); + } + + response + .body(axum::body::Body::from(daemon_response.body)) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())) +} + +async fn daemon_stream_gateway( + ws: WebSocketUpgrade, + AxumState(state): AxumState>, + OriginalUri(uri): OriginalUri, + headers: HeaderMap, +) -> Result { + if !is_daemon_stream_path(uri.path()) { + return Err(( + StatusCode::BAD_REQUEST, + "path must be a daemon stream route".to_string(), + )); + } + + let origin = header_to_str(&headers, header::ORIGIN)?; + let host = header_to_str(&headers, header::HOST)?; + if !is_allowed_daemon_stream_origin(origin, host) { + return Err(( + StatusCode::FORBIDDEN, + "daemon stream origin forbidden".to_string(), + )); + } + + let state_dir = state + .app + .storage + .lock() + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .base_dir(); + let socket_path = daemon_socket_path(&DaemonGatewayConfig { state_dir }); + let mut daemon_path = + normalize_daemon_path(uri.path()).map_err(|e| (StatusCode::BAD_REQUEST, e))?; + if let Some(query) = uri.query() { + daemon_path.push('?'); + daemon_path.push_str(query); + } + + let daemon_ws = connect_daemon_websocket(&socket_path, &daemon_path) .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; - Ok(Json(Value::String(token))) + .map_err(|_| { + ( + StatusCode::BAD_GATEWAY, + "daemon gateway unavailable".to_string(), + ) + })?; + + Ok(ws + .on_upgrade(move |socket| proxy_daemon_stream(socket, daemon_ws)) + .into_response()) +} + +fn header_to_str( + headers: &HeaderMap, + name: header::HeaderName, +) -> Result, (StatusCode, String)> { + headers + .get(name) + .map(|value| { + value.to_str().map_err(|_| { + ( + StatusCode::BAD_REQUEST, + "daemon stream handshake contains invalid header".to_string(), + ) + }) + }) + .transpose() +} + +async fn proxy_daemon_stream( + browser_ws: WebSocket, + daemon_ws: tokio_tungstenite::WebSocketStream, +) { + let (mut browser_tx, mut browser_rx) = browser_ws.split(); + let (mut daemon_tx, mut daemon_rx) = daemon_ws.split(); + + let browser_to_daemon = async { + while let Some(result) = browser_rx.next().await { + let Ok(message) = result else { + break; + }; + let is_close = matches!(message, Message::Close(_)); + if daemon_tx + .send(axum_to_tungstenite_message(message)) + .await + .is_err() + { + break; + } + if is_close { + break; + } + } + let _ = daemon_tx.close().await; + }; + + let daemon_to_browser = async { + while let Some(result) = daemon_rx.next().await { + let Ok(message) = result else { + break; + }; + let is_close = matches!(message, TungsteniteMessage::Close(_)); + let Some(message) = tungstenite_to_axum_message(message) else { + continue; + }; + if browser_tx.send(message).await.is_err() { + break; + } + if is_close { + break; + } + } + let _ = browser_tx.close().await; + }; + + tokio::select! { + _ = browser_to_daemon => {} + _ = daemon_to_browser => {} + } +} + +fn axum_to_tungstenite_message(message: Message) -> TungsteniteMessage { + match message { + Message::Text(text) => TungsteniteMessage::Text(text.to_string().into()), + Message::Binary(bytes) => TungsteniteMessage::Binary(bytes), + Message::Ping(bytes) => TungsteniteMessage::Ping(bytes), + Message::Pong(bytes) => TungsteniteMessage::Pong(bytes), + Message::Close(frame) => { + TungsteniteMessage::Close(frame.map(|frame| TungsteniteCloseFrame { + code: CloseCode::from(frame.code), + reason: frame.reason.to_string().into(), + })) + } + } +} + +fn tungstenite_to_axum_message(message: TungsteniteMessage) -> Option { + match message { + TungsteniteMessage::Text(text) => Some(Message::Text(text.to_string().into())), + TungsteniteMessage::Binary(bytes) => Some(Message::Binary(bytes)), + TungsteniteMessage::Ping(bytes) => Some(Message::Ping(bytes)), + TungsteniteMessage::Pong(bytes) => Some(Message::Pong(bytes)), + TungsteniteMessage::Close(frame) => Some(Message::Close(frame.map(|frame| { + axum::extract::ws::CloseFrame { + code: frame.code.into(), + reason: frame.reason.to_string().into(), + } + }))), + TungsteniteMessage::Frame(_) => None, + } } async fn log_frontend_error(Json(args): Json) -> Result, (StatusCode, String)> { diff --git a/server/src/models.rs b/server/src/models.rs index 10633b8..d17d704 100644 --- a/server/src/models.rs +++ b/server/src/models.rs @@ -77,6 +77,16 @@ pub struct SessionConfig { pub auto_worker_session: bool, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatWorkspaceSnapshot { + pub project_id: String, + pub workspace_id: String, + pub path: String, + pub label: String, + pub branch: Option, + pub focused: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct CommitInfo { pub hash: String, diff --git a/server/src/storage.rs b/server/src/storage.rs index 33fba22..a2364bd 100644 --- a/server/src/storage.rs +++ b/server/src/storage.rs @@ -111,6 +111,12 @@ impl Storage { /// Create a Storage using the default `~/.the-controller/` directory. pub fn with_default_path() -> io::Result { + if let Ok(base_dir) = std::env::var("THE_CONTROLLER_STATE_DIR") { + return Ok(Self { + base_dir: PathBuf::from(base_dir), + }); + } + Ok(Self { base_dir: Self::default_base_dir(dirs::home_dir())?, }) @@ -523,6 +529,39 @@ mod tests { assert_eq!(error.kind(), std::io::ErrorKind::NotFound); } + #[test] + fn test_with_default_path_uses_controller_state_dir_override() { + let tmp = TempDir::new().unwrap(); + let _guard = EnvVarGuard::set("THE_CONTROLLER_STATE_DIR", tmp.path()); + + let storage = Storage::with_default_path().unwrap(); + + assert_eq!(storage.base_dir(), tmp.path().to_path_buf()); + } + + struct EnvVarGuard { + name: &'static str, + previous: Option, + } + + impl EnvVarGuard { + fn set(name: &'static str, value: &std::path::Path) -> Self { + let previous = std::env::var(name).ok(); + std::env::set_var(name, value); + Self { name, previous } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(previous) = &self.previous { + std::env::set_var(self.name, previous); + } else { + std::env::remove_var(self.name); + } + } + } + #[test] fn test_save_and_get_agents_md_roundtrip() { let tmp = TempDir::new().unwrap(); diff --git a/server/tests/chat_workspace_links.rs b/server/tests/chat_workspace_links.rs new file mode 100644 index 0000000..0362140 --- /dev/null +++ b/server/tests/chat_workspace_links.rs @@ -0,0 +1,139 @@ +use serde_json::json; +use the_controller_lib::{ + commands::{chat_workspace_snapshot, daemon_chat_cleanup_path}, + models::{AutoWorkerConfig, ChatWorkspaceSnapshot, MaintainerConfig, Project, SessionConfig}, +}; +use uuid::Uuid; + +#[test] +fn workspace_snapshot_contains_controller_owned_fields_only() { + let snapshot = ChatWorkspaceSnapshot { + project_id: "p1".into(), + workspace_id: "w1".into(), + path: "/tmp/worktree".into(), + label: "feature-worktree".into(), + branch: Some("codex/feature".into()), + focused: true, + }; + + assert_eq!(snapshot.path, "/tmp/worktree"); +} + +#[test] +fn workspace_snapshot_serializes_for_daemon_workspace_link_api() { + let snapshot = ChatWorkspaceSnapshot { + project_id: "p1".into(), + workspace_id: "w1".into(), + path: "/tmp/worktree".into(), + label: "feature-worktree".into(), + branch: Some("codex/feature".into()), + focused: true, + }; + + assert_eq!( + serde_json::to_value(snapshot).unwrap(), + json!({ + "project_id": "p1", + "workspace_id": "w1", + "path": "/tmp/worktree", + "label": "feature-worktree", + "branch": "codex/feature", + "focused": true + }) + ); +} + +#[test] +fn workspace_snapshot_helper_uses_session_worktree_fields() { + let project_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + let session_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap(); + let project = project(project_id, "/repo/main"); + let session = session( + session_id, + "session-1-ab12cd", + Some("/tmp/worktree"), + Some("session-1-ab12cd"), + ); + + let snapshot = chat_workspace_snapshot(&project, &session, true); + + assert_eq!(snapshot.project_id, project_id.to_string()); + assert_eq!(snapshot.workspace_id, session_id.to_string()); + assert_eq!(snapshot.path, "/tmp/worktree"); + assert_eq!(snapshot.label, "session-1-ab12cd"); + assert_eq!(snapshot.branch.as_deref(), Some("session-1-ab12cd")); + assert!(snapshot.focused); +} + +#[test] +fn workspace_snapshot_helper_falls_back_to_repo_path_for_unborn_repo_sessions() { + let project = project(Uuid::new_v4(), "/repo/unborn"); + let session = session(Uuid::new_v4(), "session-1-ab12cd", None, None); + + let snapshot = chat_workspace_snapshot(&project, &session, false); + + assert_eq!(snapshot.path, "/repo/unborn"); + assert_eq!(snapshot.branch, None); + assert!(!snapshot.focused); +} + +#[test] +fn daemon_chat_cleanup_only_targets_chats_created_by_the_handler() { + assert_eq!( + daemon_chat_cleanup_path("created-chat", true).as_deref(), + Some("/api/daemon/chats/created-chat") + ); + assert_eq!(daemon_chat_cleanup_path("caller-chat", false), None); +} + +#[test] +fn controller_route_posts_workspace_snapshots_through_daemon_gateway() { + let main_source = include_str!("../src/main.rs"); + + assert!(main_source.contains("/api/create_chat_workspace")); + assert!(main_source.contains("post(create_chat_workspace)")); + assert!(main_source.contains("/api/daemon/chats/{chat_id}/workspace-links")); + assert!(main_source.contains("proxy_http_gateway(")); + assert!(main_source + .contains("cleanup_chat_workspace_session(state, project_id, session_id.clone()).await")); + assert!(main_source + .contains("commands::close_session_impl(&state.app, project_id, session_id, true)")); + assert!(main_source + .contains("cleanup_daemon_chat(&socket_path, created_chat_cleanup.as_deref()).await")); + assert!(!main_source.contains("127.0.0.1:4867")); +} + +fn project(id: Uuid, repo_path: &str) -> Project { + Project { + id, + name: "test-project".into(), + repo_path: repo_path.into(), + created_at: "2026-04-30T00:00:00Z".into(), + archived: false, + sessions: vec![], + maintainer: MaintainerConfig::default(), + auto_worker: AutoWorkerConfig::default(), + prompts: vec![], + staged_sessions: vec![], + } +} + +fn session( + id: Uuid, + label: &str, + worktree_path: Option<&str>, + worktree_branch: Option<&str>, +) -> SessionConfig { + SessionConfig { + id, + label: label.into(), + worktree_path: worktree_path.map(str::to_string), + worktree_branch: worktree_branch.map(str::to_string), + archived: false, + kind: "claude".into(), + github_issue: None, + initial_prompt: None, + done_commits: vec![], + auto_worker_session: false, + } +} diff --git a/server/tests/daemon_gateway.rs b/server/tests/daemon_gateway.rs new file mode 100644 index 0000000..0ce329b --- /dev/null +++ b/server/tests/daemon_gateway.rs @@ -0,0 +1,285 @@ +use axum::{ + body::Bytes, + http::{Method, StatusCode}, +}; +use std::collections::HashMap; +use the_controller_lib::daemon_gateway::{ + daemon_socket_path, forward_http_for_test, forward_http_with_content_type, proxy_http_gateway, + DaemonGatewayConfig, +}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::UnixListener, + task::JoinHandle, +}; + +#[test] +fn daemon_socket_path_uses_controller_state_dir() { + let tmp = tempfile::tempdir().unwrap(); + let cfg = DaemonGatewayConfig { + state_dir: tmp.path().to_path_buf(), + }; + + assert_eq!(daemon_socket_path(&cfg), tmp.path().join("daemon.sock")); +} + +#[test] +fn gateway_paths_must_stay_under_api_daemon() { + assert_eq!( + the_controller_lib::daemon_gateway::normalize_daemon_path("/api/daemon/profiles").unwrap(), + "/profiles" + ); + assert!( + the_controller_lib::daemon_gateway::normalize_daemon_path("/api/list_projects").is_err() + ); + assert!(the_controller_lib::daemon_gateway::normalize_daemon_path("/api/daemonish").is_err()); + assert!( + the_controller_lib::daemon_gateway::normalize_daemon_path("/api/daemon/../projects") + .is_err() + ); + assert!(the_controller_lib::daemon_gateway::normalize_daemon_path( + "/api/daemon/%2e%2e/projects" + ) + .is_err()); + assert!(the_controller_lib::daemon_gateway::normalize_daemon_path( + "/api/daemon/%2E%2E/projects" + ) + .is_err()); +} + +#[test] +fn gateway_does_not_expose_daemon_token_route() { + let main_source = include_str!("../src/main.rs"); + let commands_source = include_str!("../src/commands.rs"); + + assert!(!main_source.contains("/api/read_daemon_token")); + assert!(!main_source.contains("fn read_daemon_token")); + assert!(!commands_source.contains("pub mod daemon")); +} + +#[test] +fn daemon_gateway_is_not_exposed_to_permissive_cors() { + let main_source = include_str!("../src/main.rs"); + + assert!(!main_source.contains("tower_http::cors")); + assert!(!main_source.contains("CorsLayer")); + assert!(!main_source.contains("CorsLayer::permissive()")); +} + +#[tokio::test] +async fn gateway_health_uses_unix_socket() { + let tmp = tempfile::tempdir().unwrap(); + let socket = tmp.path().join("daemon.sock"); + let server = serve_uds_health(&socket).await; + + let body = forward_http_for_test(&socket, "/health").await.unwrap(); + + assert_eq!(body.status, 200); + assert_eq!(body.body.as_ref(), b"ok"); + server.abort(); +} + +#[tokio::test] +async fn gateway_post_forwards_request_content_type() { + let tmp = tempfile::tempdir().unwrap(); + let socket = tmp.path().join("daemon.sock"); + let server = serve_uds_content_type_echo(&socket).await; + + let body = forward_http_with_content_type( + &socket, + Method::POST, + "/sessions".to_string(), + Bytes::from_static(br#"{"cwd":"/tmp"}"#), + Some("application/json".to_string()), + ) + .await + .unwrap(); + + assert_eq!(body.status, 200); + assert_eq!(body.body.as_ref(), b"application/json"); + server.abort(); +} + +#[tokio::test] +async fn gateway_proxy_preserves_query_and_body_over_unix_socket() { + let tmp = tempfile::tempdir().unwrap(); + let socket = tmp.path().join("daemon.sock"); + let server = serve_uds_request_echo(&socket).await; + + let response = proxy_http_gateway( + &socket, + Method::POST, + "/api/daemon/sessions", + Some("since=5&channels=stdout%2Cstderr"), + Bytes::from_static(br#"{"cwd":"/tmp/project"}"#), + Some("application/json".to_string()), + ) + .await + .unwrap(); + + assert_eq!(response.status, 200); + assert_eq!(response.content_type.as_deref(), Some("text/plain")); + assert_eq!( + response.body.as_ref(), + br#"POST /sessions?since=5&channels=stdout%2Cstderr application/json {"cwd":"/tmp/project"}"# + ); + server.abort(); +} + +#[tokio::test] +async fn gateway_proxy_maps_missing_socket_to_bad_gateway() { + let tmp = tempfile::tempdir().unwrap(); + let missing_socket = tmp.path().join("missing-daemon.sock"); + + let error = proxy_http_gateway( + &missing_socket, + Method::GET, + "/api/daemon/health", + None, + Bytes::new(), + None, + ) + .await + .unwrap_err(); + + assert_eq!(error.0, StatusCode::BAD_GATEWAY); + assert_eq!(error.1, "daemon gateway unavailable"); +} + +async fn serve_uds_health(socket: &std::path::Path) -> JoinHandle<()> { + let listener = UnixListener::bind(socket).unwrap(); + tokio::spawn(async move { + loop { + let Ok((mut stream, _addr)) = listener.accept().await else { + return; + }; + tokio::spawn(async move { + let Some(request) = read_http_request(&mut stream).await else { + return; + }; + let response = if request.method == "GET" && request.target == "/health" { + "HTTP/1.1 200 OK\r\ncontent-type: text/plain\r\ncontent-length: 2\r\n\r\nok" + } else { + "HTTP/1.1 404 Not Found\r\ncontent-length: 0\r\n\r\n" + }; + let _ = stream.write_all(response.as_bytes()).await; + }); + } + }) +} + +async fn serve_uds_content_type_echo(socket: &std::path::Path) -> JoinHandle<()> { + let listener = UnixListener::bind(socket).unwrap(); + tokio::spawn(async move { + let Ok((mut stream, _addr)) = listener.accept().await else { + return; + }; + let Some(request) = read_http_request(&mut stream).await else { + return; + }; + let content_type = request.header("content-type").unwrap_or_default(); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: text/plain\r\ncontent-length: {}\r\n\r\n{}", + content_type.len(), + content_type + ); + let _ = stream.write_all(response.as_bytes()).await; + }) +} + +async fn serve_uds_request_echo(socket: &std::path::Path) -> JoinHandle<()> { + let listener = UnixListener::bind(socket).unwrap(); + tokio::spawn(async move { + let Ok((mut stream, _addr)) = listener.accept().await else { + return; + }; + let Some(request) = read_http_request(&mut stream).await else { + return; + }; + let content_type = request.header("content-type").unwrap_or_default(); + let body = String::from_utf8_lossy(&request.body); + let response_body = format!( + "{} {} {} {}", + request.method, request.target, content_type, body + ); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\n\r\n{}", + response_body.len(), + response_body + ); + let _ = stream.write_all(response.as_bytes()).await; + }) +} + +struct CapturedRequest { + method: String, + target: String, + headers: HashMap, + body: Vec, +} + +impl CapturedRequest { + fn header(&self, name: &str) -> Option { + self.headers.get(&name.to_ascii_lowercase()).cloned() + } +} + +async fn read_http_request(stream: &mut tokio::net::UnixStream) -> Option { + let mut buffer = Vec::new(); + let header_end = loop { + if let Some(index) = find_header_end(&buffer) { + break index; + } + let mut chunk = [0; 512]; + let read = stream.read(&mut chunk).await.ok()?; + if read == 0 { + return None; + } + buffer.extend_from_slice(&chunk[..read]); + }; + + let header_bytes = &buffer[..header_end]; + let header_text = String::from_utf8_lossy(header_bytes); + let mut lines = header_text.split("\r\n"); + let request_line = lines.next()?; + let mut request_parts = request_line.split_whitespace(); + let method = request_parts.next()?.to_string(); + let target = request_parts.next()?.to_string(); + let mut headers = HashMap::new(); + + for line in lines { + if line.is_empty() { + continue; + } + let Some((name, value)) = line.split_once(':') else { + continue; + }; + headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string()); + } + + let content_length = headers + .get("content-length") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + let body_start = header_end + 4; + while buffer.len() < body_start + content_length { + let mut chunk = [0; 512]; + let read = stream.read(&mut chunk).await.ok()?; + if read == 0 { + return None; + } + buffer.extend_from_slice(&chunk[..read]); + } + let body = buffer[body_start..body_start + content_length].to_vec(); + + Some(CapturedRequest { + method, + target, + headers, + body, + }) +} + +fn find_header_end(bytes: &[u8]) -> Option { + bytes.windows(4).position(|window| window == b"\r\n\r\n") +} diff --git a/server/tests/daemon_gateway_ws.rs b/server/tests/daemon_gateway_ws.rs new file mode 100644 index 0000000..daedc4f --- /dev/null +++ b/server/tests/daemon_gateway_ws.rs @@ -0,0 +1,120 @@ +use futures_util::{SinkExt, StreamExt}; +use the_controller_lib::daemon_gateway::{ + connect_daemon_websocket, is_allowed_daemon_stream_origin, +}; +use tokio::{net::UnixListener, sync::oneshot}; +use tokio_tungstenite::{ + accept_hdr_async, + tungstenite::{ + handshake::server::{Request, Response}, + Message, + }, +}; + +async fn ok() {} + +#[test] +fn stream_paths_are_websocket_gateway_paths() { + assert!(the_controller_lib::daemon_gateway::is_daemon_stream_path( + "/api/daemon/chats/c1/stream" + )); + assert!(the_controller_lib::daemon_gateway::is_daemon_stream_path( + "/api/daemon/sessions/s1/stream" + )); + assert!(!the_controller_lib::daemon_gateway::is_daemon_stream_path( + "/api/daemon/chats" + )); + assert!(!the_controller_lib::daemon_gateway::is_daemon_stream_path( + "/api/daemon/chats/c1/messages" + )); + assert!(!the_controller_lib::daemon_gateway::is_daemon_stream_path( + "/api/daemon/other/c1/stream" + )); +} + +#[test] +fn explicit_stream_routes_can_coexist_with_daemon_catch_all() { + let _router: axum::Router<()> = axum::Router::new() + .route("/api/daemon/chats/{id}/stream", axum::routing::get(ok)) + .route("/api/daemon/sessions/{id}/stream", axum::routing::get(ok)) + .route("/api/daemon/{*path}", axum::routing::get(ok)); +} + +#[test] +fn stream_origin_policy_allows_native_and_same_origin_requests() { + assert!(is_allowed_daemon_stream_origin( + None, + Some("localhost:3001") + )); + assert!(is_allowed_daemon_stream_origin( + Some("http://localhost:3001"), + Some("localhost:3001") + )); + assert!(is_allowed_daemon_stream_origin( + Some("http://127.0.0.1:1934"), + Some("127.0.0.1:1934") + )); +} + +#[test] +fn stream_origin_policy_rejects_foreign_or_malformed_origins() { + assert!(!is_allowed_daemon_stream_origin( + Some("https://evil.example"), + Some("localhost:3001") + )); + assert!(!is_allowed_daemon_stream_origin( + Some("not a url"), + Some("localhost:3001") + )); + assert!(!is_allowed_daemon_stream_origin( + Some("http://localhost:1420"), + None + )); + assert!(!is_allowed_daemon_stream_origin( + Some("http://localhost:1420"), + Some("localhost:3001") + )); + assert!(!is_allowed_daemon_stream_origin( + Some("http://evil.example:3001"), + Some("evil.example:3001") + )); +} + +#[tokio::test] +async fn daemon_websocket_connects_over_unix_socket_and_preserves_query() { + let tmp = tempfile::tempdir().unwrap(); + let socket = tmp.path().join("daemon.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let (target_tx, target_rx) = oneshot::channel(); + + let server = tokio::spawn(async move { + let (stream, _addr) = listener.accept().await.unwrap(); + let mut ws = accept_hdr_async(stream, move |request: &Request, response: Response| { + let _ = target_tx.send(request.uri().to_string()); + Ok(response) + }) + .await + .unwrap(); + + let msg = ws.next().await.unwrap().unwrap(); + assert_eq!(msg, Message::Text("hello".into())); + ws.send(Message::Text("ack".into())).await.unwrap(); + }); + + let mut ws = connect_daemon_websocket( + &socket, + "/sessions/s1/stream?since=5&channels=stdout%2Cstderr", + ) + .await + .unwrap(); + + ws.send(Message::Text("hello".into())).await.unwrap(); + let reply = ws.next().await.unwrap().unwrap(); + + assert_eq!( + target_rx.await.unwrap(), + "/sessions/s1/stream?since=5&channels=stdout%2Cstderr" + ); + assert_eq!(reply, Message::Text("ack".into())); + server.await.unwrap(); +} diff --git a/src/App.svelte b/src/App.svelte index 4125b3f..540dbdc 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -12,6 +12,8 @@ import KeystrokeVisualizer from "./lib/KeystrokeVisualizer.svelte"; import WorkspaceModePicker from "./lib/WorkspaceModePicker.svelte"; import AgentDashboard from "./lib/AgentDashboard.svelte"; + import AgentCreationWorkspace from "./lib/agents/AgentCreationWorkspace.svelte"; + import AgentObservabilityWorkspace from "./lib/observability/AgentObservabilityWorkspace.svelte"; import KanbanBoard from "./lib/KanbanBoard.svelte"; import ChatWorkspace from "./lib/chat/ChatWorkspace.svelte"; import { refreshProjectsFromBackend } from "./lib/project-listing"; @@ -175,6 +177,10 @@
{#if workspaceModeState.current === "agents"} + {:else if workspaceModeState.current === "agent-create"} + + {:else if workspaceModeState.current === "agent-observe"} + {:else if workspaceModeState.current === "kanban"} {:else} diff --git a/src/App.test.ts b/src/App.test.ts index adb015a..90f9446 100644 --- a/src/App.test.ts +++ b/src/App.test.ts @@ -26,6 +26,8 @@ const componentMocks = vi.hoisted(() => ({ keystrokeVisualizer: vi.fn(), workspaceModePicker: vi.fn(), agentDashboard: vi.fn(), + agentCreationWorkspace: vi.fn(), + agentObservabilityWorkspace: vi.fn(), kanbanBoard: vi.fn(), chatWorkspace: vi.fn(), })); @@ -38,6 +40,8 @@ vi.mock("./lib/HotkeyHelp.svelte", () => ({ default: componentMocks.hotkeyHelp } vi.mock("./lib/KeystrokeVisualizer.svelte", () => ({ default: componentMocks.keystrokeVisualizer })); vi.mock("./lib/WorkspaceModePicker.svelte", () => ({ default: componentMocks.workspaceModePicker })); vi.mock("./lib/AgentDashboard.svelte", () => ({ default: componentMocks.agentDashboard })); +vi.mock("./lib/agents/AgentCreationWorkspace.svelte", () => ({ default: componentMocks.agentCreationWorkspace })); +vi.mock("./lib/observability/AgentObservabilityWorkspace.svelte", () => ({ default: componentMocks.agentObservabilityWorkspace })); vi.mock("./lib/KanbanBoard.svelte", () => ({ default: componentMocks.kanbanBoard })); vi.mock("./lib/chat/ChatWorkspace.svelte", () => ({ default: componentMocks.chatWorkspace })); vi.mock("./lib/toast", () => ({ @@ -106,13 +110,28 @@ describe("App shell", () => { expect(document.title).toBe("The Controller (test-commit, test-branch, localhost:1420)"); }); - it("renders the agents and kanban workspaces", async () => { + it("renders the agents, agent creation, observability, and kanban workspaces", async () => { workspaceMode.set("agents"); render(App); await waitFor(() => { expect(componentMocks.agentDashboard).toHaveBeenCalled(); }); + vi.clearAllMocks(); + workspaceMode.set("agent-create"); + render(App); + await waitFor(() => { + expect(componentMocks.agentCreationWorkspace).toHaveBeenCalled(); + }); + + vi.clearAllMocks(); + workspaceMode.set("agent-observe"); + render(App); + await waitFor(() => { + expect(componentMocks.agentObservabilityWorkspace).toHaveBeenCalled(); + }); + expect(componentMocks.chatWorkspace).not.toHaveBeenCalled(); + vi.clearAllMocks(); workspaceMode.set("kanban"); render(App); diff --git a/src/lib/HotkeyHelp.svelte b/src/lib/HotkeyHelp.svelte index c7f2f41..98a3b46 100644 --- a/src/lib/HotkeyHelp.svelte +++ b/src/lib/HotkeyHelp.svelte @@ -14,7 +14,9 @@ const sections = $derived(getHelpSections(workspaceModeState.current)); const modeLabel = $derived( workspaceModeState.current === "agents" ? "Agents" : - workspaceModeState.current === "kanban" ? "Kanban" : "Chat" + workspaceModeState.current === "kanban" ? "Kanban" : + workspaceModeState.current === "agent-create" ? "Agent Creation" : + workspaceModeState.current === "agent-observe" ? "Agent Observe" : "Chat" ); function handleKeydown(e: KeyboardEvent) { diff --git a/src/lib/HotkeyManager.svelte b/src/lib/HotkeyManager.svelte index 8d2af5c..49a3d5d 100644 --- a/src/lib/HotkeyManager.svelte +++ b/src/lib/HotkeyManager.svelte @@ -54,6 +54,9 @@ : undefined; projectId = activeProject?.id ?? null; } + if (!projectId && daemonStore.activeChatId) { + projectId = daemonStore.chats.get(daemonStore.activeChatId)?.project_id ?? null; + } return projectList.find((project) => project.id === projectId) ?? projectList[0]; } @@ -82,12 +85,21 @@ } if (key === "c") { switchWorkspaceMode("chat"); + return; + } + if (key === "p") { + switchWorkspaceMode("agent-create"); + return; + } + if (key === "o") { + switchWorkspaceMode("agent-observe"); } } type SidebarItem = | { type: "project"; projectId: string } | { type: "session"; sessionId: string; projectId: string } + | { type: "chat"; chatId: string; projectId: string } | { type: "agent"; agentKind: "auto-worker" | "maintainer"; projectId: string }; function getVisibleItems(): SidebarItem[] { @@ -105,6 +117,7 @@ if (currentMode === "chat") { const result: SidebarItem[] = []; const daemonSessions = [...daemonStore.sessions.values()]; + const daemonChats = [...daemonStore.chats.values()]; for (const p of projectList) { result.push({ type: "project", projectId: p.id }); if (!expandedSet.has(p.id)) continue; @@ -113,6 +126,11 @@ result.push({ type: "session", sessionId: s.id, projectId: p.id }); } } + for (const chat of daemonChats) { + if (chat.project_id === p.id) { + result.push({ type: "chat", chatId: chat.id, projectId: p.id }); + } + } } return result; } @@ -126,6 +144,8 @@ let idx = -1; if (currentFocus?.type === "session") { idx = items.findIndex(it => it.type === "session" && it.sessionId === currentFocus.sessionId); + } else if (currentFocus?.type === "chat") { + idx = items.findIndex(it => it.type === "chat" && it.chatId === currentFocus.chatId); } else if (currentFocus?.type === "agent") { idx = items.findIndex(it => it.type === "agent" && it.projectId === currentFocus.projectId && it.agentKind === currentFocus.agentKind); } else if (currentFocus?.type === "project") { @@ -135,7 +155,12 @@ const next = items[((idx + direction) % len + len) % len]; if (next.type === "session") { daemonStore.activeSessionId = next.sessionId; + daemonStore.activeChatId = null; focusTarget.set({ type: "session", sessionId: next.sessionId, projectId: next.projectId }); + } else if (next.type === "chat") { + daemonStore.activeChatId = next.chatId; + daemonStore.activeSessionId = null; + focusTarget.set({ type: "chat", chatId: next.chatId, projectId: next.projectId }); } else if (next.type === "agent") { focusTarget.set({ type: "agent", agentKind: next.agentKind, projectId: next.projectId }); } else { @@ -168,6 +193,10 @@ expandedProjects.set(next); } else if (currentFocus?.type === "session") { daemonStore.activeSessionId = currentFocus.sessionId; + daemonStore.activeChatId = null; + } else if (currentFocus?.type === "chat") { + daemonStore.activeChatId = currentFocus.chatId; + daemonStore.activeSessionId = null; } else if (currentFocus?.type === "agent") { focusTarget.set({ type: "agent-panel", agentKind: currentFocus.agentKind, projectId: currentFocus.projectId }); } @@ -229,7 +258,7 @@ if (isEditableElementFocused()) return; if (e.key === "Escape") { - if (currentFocus?.type === "session") { + if (currentFocus?.type === "session" || currentFocus?.type === "chat") { focusTarget.set({ type: "project", projectId: currentFocus.projectId }); e.stopPropagation(); e.preventDefault(); diff --git a/src/lib/HotkeyManager.test.ts b/src/lib/HotkeyManager.test.ts index 64886c4..547668e 100644 --- a/src/lib/HotkeyManager.test.ts +++ b/src/lib/HotkeyManager.test.ts @@ -138,7 +138,7 @@ describe("HotkeyManager", () => { expect(get(workspaceModePickerVisible)).toBe(true); }); - it("Space switches only among agents, kanban, and chat", () => { + it("Space switches among workspace modes", () => { pressKey(" "); pressKey("a"); expect(get(workspaceMode)).toBe("agents"); @@ -150,6 +150,14 @@ describe("HotkeyManager", () => { pressKey(" "); pressKey("c"); expect(get(workspaceMode)).toBe("chat"); + + pressKey(" "); + pressKey("p"); + expect(get(workspaceMode)).toBe("agent-create"); + + pressKey(" "); + pressKey("o"); + expect(get(workspaceMode)).toBe("agent-observe"); }); it("Space then d closes the picker without leaving the current mode", () => { diff --git a/src/lib/Sidebar.svelte b/src/lib/Sidebar.svelte index 6c6e0ab..e8aa1a0 100644 --- a/src/lib/Sidebar.svelte +++ b/src/lib/Sidebar.svelte @@ -37,7 +37,9 @@ let modeTitle = $derived( currentMode === "agents" ? "Agents" : - currentMode === "kanban" ? "Kanban" : "Chat", + currentMode === "kanban" ? "Kanban" : + currentMode === "agent-create" ? "Create" : + currentMode === "agent-observe" ? "Observe" : "Chat", ); $effect(() => { @@ -56,6 +58,15 @@ requestAnimationFrame(() => { sidebarEl?.querySelector(`[data-session-id="${currentFocus.sessionId}"]`)?.focus(); }); + } else if (currentFocus?.type === "chat") { + if (!expandedProjectSet.has(currentFocus.projectId)) { + const next = new Set(expandedProjectSet); + next.add(currentFocus.projectId); + expandedProjects.set(next); + } + requestAnimationFrame(() => { + sidebarEl?.querySelector(`[data-chat-id="${currentFocus.chatId}"]`)?.focus(); + }); } else if (currentFocus?.type === "agent") { if (!expandedProjectSet.has(currentFocus.projectId)) { const next = new Set(expandedProjectSet); @@ -206,6 +217,11 @@ }} onSelect={(sessionId) => { daemonStore.activeSessionId = sessionId; + daemonStore.activeChatId = null; + }} + onSelectChat={(chatId) => { + daemonStore.activeChatId = chatId; + daemonStore.activeSessionId = null; }} /> {/if} diff --git a/src/lib/Sidebar.test.ts b/src/lib/Sidebar.test.ts index 107bfa4..ed3b73c 100644 --- a/src/lib/Sidebar.test.ts +++ b/src/lib/Sidebar.test.ts @@ -51,7 +51,7 @@ describe("Sidebar", () => { }); }); - it("updates the sidebar header for agents and kanban", async () => { + it("updates the sidebar header for workspace modes", async () => { render(Sidebar); workspaceMode.set("agents"); @@ -63,6 +63,16 @@ describe("Sidebar", () => { await waitFor(() => { expect(screen.getByText("Kanban")).toBeInTheDocument(); }); + + workspaceMode.set("agent-create"); + await waitFor(() => { + expect(screen.getByText("Create")).toBeInTheDocument(); + }); + + workspaceMode.set("agent-observe"); + await waitFor(() => { + expect(screen.getByText("Observe")).toBeInTheDocument(); + }); }); it("surfaces corrupt project metadata returned by list_projects", async () => { diff --git a/src/lib/WorkspaceModePicker.svelte b/src/lib/WorkspaceModePicker.svelte index 5249e9b..bb6edba 100644 --- a/src/lib/WorkspaceModePicker.svelte +++ b/src/lib/WorkspaceModePicker.svelte @@ -9,6 +9,8 @@ { key: "a", id: "agents", label: "Agents" }, { key: "k", id: "kanban", label: "Kanban" }, { key: "c", id: "chat", label: "Chat" }, + { key: "p", id: "agent-create", label: "Create" }, + { key: "o", id: "agent-observe", label: "Observe" }, ]; diff --git a/src/lib/__tests__/WorkspaceModePicker.test.ts b/src/lib/__tests__/WorkspaceModePicker.test.ts index 518f607..4163e6a 100644 --- a/src/lib/__tests__/WorkspaceModePicker.test.ts +++ b/src/lib/__tests__/WorkspaceModePicker.test.ts @@ -3,17 +3,23 @@ import { render } from "@testing-library/svelte"; import WorkspaceModePicker from "../WorkspaceModePicker.svelte"; describe("WorkspaceModePicker", () => { - it("lists only agents, kanban, and chat", () => { + it("lists the available workspace modes with compact keys", () => { const { getByText, queryByText, container } = render(WorkspaceModePicker); expect(queryByText("Development")).toBeNull(); - expect(container.querySelectorAll(".picker-option")).toHaveLength(3); + expect(container.querySelectorAll(".picker-option")).toHaveLength(5); expect(getByText("Agents")).toBeTruthy(); expect(getByText("Kanban")).toBeTruthy(); expect(getByText("Chat")).toBeTruthy(); + expect(getByText("Create")).toBeTruthy(); + expect(getByText("Observe")).toBeTruthy(); const chat = getByText("Chat"); const row = chat.closest(".picker-option")!; expect(row.querySelector("kbd")!.textContent).toBe("c"); + + const create = getByText("Create"); + const createRow = create.closest(".picker-option")!; + expect(createRow.querySelector("kbd")!.textContent).toBe("p"); }); }); diff --git a/src/lib/agents/AgentCreationWorkspace.svelte b/src/lib/agents/AgentCreationWorkspace.svelte new file mode 100644 index 0000000..6084af7 --- /dev/null +++ b/src/lib/agents/AgentCreationWorkspace.svelte @@ -0,0 +1,358 @@ + + +
+ + +
+ {#if draft} + (previewOpen = !previewOpen)} + /> + (previewOpen = false)} + /> + {:else} +
+
+

Agent Profiles

+

No agent profiles

+

Profiles become available as @agent and %agent in chat.

+ +
+
+ {/if} +
+
+ + diff --git a/src/lib/agents/AgentCreationWorkspace.test.svelte.ts b/src/lib/agents/AgentCreationWorkspace.test.svelte.ts new file mode 100644 index 0000000..55510d2 --- /dev/null +++ b/src/lib/agents/AgentCreationWorkspace.test.svelte.ts @@ -0,0 +1,186 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import AgentCreationWorkspace from "./AgentCreationWorkspace.svelte"; +import { daemonStore } from "../daemon/store.svelte"; +import type { AgentProfile, SavedAgentProfile } from "../daemon/types"; + +function makeProfile(overrides: Partial = {}): AgentProfile { + return { + id: "profile-1", + handle: "reviewer", + name: "Reviewer", + description: "Reviews changes", + runtime: "codex", + skills: ["code-review"], + prompt: "Review the active diff and return concise findings.", + archived_at: null, + avatar_asset_path: null, + avatar_status: "pending", + avatar_error: null, + active_version_id: "version-1", + created_at: 1, + updated_at: 1, + ...overrides, + }; +} + +function makeSaved(profile: AgentProfile): SavedAgentProfile { + return { + profile, + version: { + id: "version-2", + profile_id: profile.id, + runtime: profile.runtime, + model: "gpt-5", + prompt: profile.prompt, + skills: profile.skills, + default_workspace_behavior: "focused", + outbox_instructions: "Reply with a concise summary.", + validation_result: null, + created_at: 2, + }, + }; +} + +describe("AgentCreationWorkspace", () => { + beforeEach(() => { + daemonStore.reachable = true; + daemonStore.profiles.clear(); + daemonStore.client = { + saveProfile: vi.fn(), + archiveProfile: vi.fn(), + restoreProfile: vi.fn(), + testProfileInChat: vi.fn(), + } as any; + }); + + it("shows the profile empty state", () => { + render(AgentCreationWorkspace); + + expect(screen.getByText("No agent profiles")).toBeTruthy(); + expect(screen.getByRole("button", { name: "New Profile" })).toBeTruthy(); + expect(screen.getByText(/profiles become available as @agent and %agent in chat/i)).toBeTruthy(); + }); + + it("opens a new draft from the empty state", async () => { + render(AgentCreationWorkspace); + + await fireEvent.click(screen.getByRole("button", { name: "New Profile" })); + + expect(screen.getByLabelText("Name")).toBeTruthy(); + expect(screen.getByLabelText("Handle")).toBeTruthy(); + expect(screen.getByText("Unsaved draft")).toBeTruthy(); + }); + + it("opens a new draft with n when text input is not focused", async () => { + daemonStore.profiles.set("profile-1", makeProfile()); + render(AgentCreationWorkspace); + + window.dispatchEvent(new KeyboardEvent("keydown", { key: "n", bubbles: true })); + + expect(await screen.findByText("Unsaved draft")).toBeTruthy(); + expect(screen.getByLabelText("Handle")).toHaveValue(""); + }); + + it("filters active and archived profile rows into separate views", async () => { + daemonStore.profiles.set("active-profile", makeProfile({ + id: "active-profile", + name: "Active Reviewer", + handle: "active-reviewer", + })); + daemonStore.profiles.set("archived-profile", makeProfile({ + id: "archived-profile", + name: "Archived Reviewer", + handle: "archived-reviewer", + archived_at: 42, + })); + + render(AgentCreationWorkspace); + + const list = screen.getByRole("listbox", { name: "Profiles" }); + expect(within(list).getByText("Active Reviewer")).toBeTruthy(); + expect(within(list).queryByText("Archived Reviewer")).toBeNull(); + + await fireEvent.click(screen.getByRole("button", { name: "Archived" })); + + expect(within(list).queryByText("Active Reviewer")).toBeNull(); + expect(within(list).getByText("Archived Reviewer")).toBeTruthy(); + }); + + it("disables save when the handle is invalid", async () => { + render(AgentCreationWorkspace); + await fireEvent.click(screen.getByRole("button", { name: "New Profile" })); + + await userEvent.type(screen.getByLabelText("Name"), "Reviewer"); + await userEvent.type(screen.getByLabelText("Handle"), "Reviewer!"); + await userEvent.type(screen.getByLabelText("System Prompt"), "Review the active diff."); + + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + expect(screen.getByText("Handle can use lowercase letters, numbers, and hyphens only.")).toBeTruthy(); + }); + + it("saves a valid draft through the daemon client and updates the profile store", async () => { + const savedProfile = makeProfile(); + vi.mocked(daemonStore.client!.saveProfile).mockResolvedValueOnce(makeSaved(savedProfile)); + render(AgentCreationWorkspace); + await fireEvent.click(screen.getByRole("button", { name: "New Profile" })); + + await userEvent.type(screen.getByLabelText("Name"), "Reviewer"); + await userEvent.type(screen.getByLabelText("Handle"), "reviewer"); + await userEvent.type(screen.getByLabelText("System Prompt"), "Review the active diff."); + + await fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(daemonStore.client!.saveProfile).toHaveBeenCalledWith(expect.objectContaining({ + name: "Reviewer", + handle: "reviewer", + runtime: "codex", + prompt: "Review the active diff.", + })); + expect(daemonStore.profiles.get("profile-1")).toEqual(savedProfile); + }); + }); + + it("saves a new draft with blank optional strings without sending null values", async () => { + const savedProfile = makeProfile(); + vi.mocked(daemonStore.client!.saveProfile).mockResolvedValueOnce(makeSaved(savedProfile)); + render(AgentCreationWorkspace); + await fireEvent.click(screen.getByRole("button", { name: "New Profile" })); + + await userEvent.type(screen.getByLabelText("Name"), "Reviewer"); + await userEvent.type(screen.getByLabelText("Handle"), "reviewer"); + await userEvent.type(screen.getByLabelText("System Prompt"), "Review the active diff."); + + await fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => expect(daemonStore.client!.saveProfile).toHaveBeenCalled()); + const request = vi.mocked(daemonStore.client!.saveProfile).mock.calls[0][0]; + expect(Object.values(request)).not.toContain(null); + expect(request.description).toBeUndefined(); + expect(request.outbox_instructions).toBeUndefined(); + }); + + it("does not send unknown active-version fields when saving an existing profile name edit", async () => { + const profile = makeProfile(); + const savedProfile = makeProfile({ name: "Reviewer Updated" }); + daemonStore.profiles.set(profile.id, profile); + vi.mocked(daemonStore.client!.saveProfile).mockResolvedValueOnce(makeSaved(savedProfile)); + render(AgentCreationWorkspace); + + await userEvent.clear(await screen.findByLabelText("Name")); + await userEvent.type(screen.getByLabelText("Name"), "Reviewer Updated"); + await fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => expect(daemonStore.client!.saveProfile).toHaveBeenCalled()); + const request = vi.mocked(daemonStore.client!.saveProfile).mock.calls[0][0]; + expect(request).toMatchObject({ + id: profile.id, + name: "Reviewer Updated", + }); + expect(request).not.toHaveProperty("model"); + expect(request).not.toHaveProperty("default_workspace_behavior"); + expect(request).not.toHaveProperty("outbox_instructions"); + }); +}); diff --git a/src/lib/agents/ProfileEditor.svelte b/src/lib/agents/ProfileEditor.svelte new file mode 100644 index 0000000..ab64bb9 --- /dev/null +++ b/src/lib/agents/ProfileEditor.svelte @@ -0,0 +1,382 @@ + + +
+
+
+
{initials(draft.name || draft.handle)}
+
+

{draft.name || "Unsaved draft"}

+
+ @{draft.handle || "handle"} + {draft.runtime || "runtime"} + {#if dirty} + Unsaved draft + {:else if saved} + Saved + {/if} +
+
+
+ +
+ + + {#if archived} + + {:else} + + {/if} + + +
+
+ + {#if !clientAvailable} +
Daemon client unavailable. Profile actions are disabled.
+ {/if} + {#if actionError} +
{actionError}
+ {/if} + +
+ {#if validation.blocking.length === 0} + handle: valid + {:else} + {#each validation.blocking as item} + {item.message} + {/each} + {/if} + {#each validation.warnings as item} + {item.message} + {/each} +
+ +
{ event.preventDefault(); if (canSave) onSave(); }}> +
+ + + + +
+ + + + + +
+ + +
+ + +
+
+ + diff --git a/src/lib/agents/ProfileList.svelte b/src/lib/agents/ProfileList.svelte new file mode 100644 index 0000000..1be98c4 --- /dev/null +++ b/src/lib/agents/ProfileList.svelte @@ -0,0 +1,305 @@ + + + + + diff --git a/src/lib/agents/ProfilePreviewDrawer.svelte b/src/lib/agents/ProfilePreviewDrawer.svelte new file mode 100644 index 0000000..9dbec94 --- /dev/null +++ b/src/lib/agents/ProfilePreviewDrawer.svelte @@ -0,0 +1,189 @@ + + +{#if open} + +{/if} + + diff --git a/src/lib/agents/profile-validation.test.ts b/src/lib/agents/profile-validation.test.ts new file mode 100644 index 0000000..4cf08f4 --- /dev/null +++ b/src/lib/agents/profile-validation.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { validateProfileDraft, type ProfileDraft } from "./profile-validation"; + +describe("validateProfileDraft", () => { + it("blocks unsupported handles and empty prompts while warning for optional launch context", () => { + const draft: ProfileDraft = { + name: "Reviewer", + handle: "Reviewer!", + runtime: "codex", + model: "", + description: "", + prompt: "", + skills: [], + outbox_instructions: "", + default_workspace_behavior: "focused", + }; + + const result = validateProfileDraft(draft); + + expect(result.blocking).toEqual([ + { field: "handle", message: "Handle can use lowercase letters, numbers, and hyphens only." }, + { field: "prompt", message: "System prompt is required." }, + ]); + expect(result.warnings).toEqual([ + { field: "skills", message: "No skills selected." }, + { field: "outbox_instructions", message: "Outbox instructions are empty." }, + ]); + }); +}); diff --git a/src/lib/agents/profile-validation.ts b/src/lib/agents/profile-validation.ts new file mode 100644 index 0000000..bf93364 --- /dev/null +++ b/src/lib/agents/profile-validation.ts @@ -0,0 +1,69 @@ +import type { Agent } from "../daemon/types"; + +export type ProfileDraftField = + | "name" + | "handle" + | "runtime" + | "model" + | "description" + | "prompt" + | "skills" + | "outbox_instructions" + | "default_workspace_behavior"; + +export interface ProfileDraft { + id?: string; + name: string; + handle: string; + runtime: Agent | ""; + model: string; + description: string; + prompt: string; + skills: string[]; + outbox_instructions: string; + default_workspace_behavior: string; +} + +export interface ProfileValidationMessage { + field: ProfileDraftField; + message: string; +} + +export interface ProfileValidationResult { + blocking: ProfileValidationMessage[]; + warnings: ProfileValidationMessage[]; +} + +const HANDLE_PATTERN = /^[a-z0-9-]+$/; + +export function validateProfileDraft(draft: ProfileDraft): ProfileValidationResult { + const blocking: ProfileValidationMessage[] = []; + const warnings: ProfileValidationMessage[] = []; + + if (!draft.name.trim()) { + blocking.push({ field: "name", message: "Name is required." }); + } + if (!draft.handle.trim()) { + blocking.push({ field: "handle", message: "Handle is required." }); + } else if (!HANDLE_PATTERN.test(draft.handle.trim())) { + blocking.push({ field: "handle", message: "Handle can use lowercase letters, numbers, and hyphens only." }); + } + if (!draft.runtime) { + blocking.push({ field: "runtime", message: "Runtime is required." }); + } + if (!draft.prompt.trim()) { + blocking.push({ field: "prompt", message: "System prompt is required." }); + } + + if (draft.skills.length === 0) { + warnings.push({ field: "skills", message: "No skills selected." }); + } + if (!draft.outbox_instructions.trim()) { + warnings.push({ field: "outbox_instructions", message: "Outbox instructions are empty." }); + } + if (!draft.default_workspace_behavior.trim()) { + warnings.push({ field: "default_workspace_behavior", message: "Workspace behavior is unset." }); + } + + return { blocking, warnings }; +} diff --git a/src/lib/chat/AgentTokenMenu.svelte b/src/lib/chat/AgentTokenMenu.svelte new file mode 100644 index 0000000..a1b1e47 --- /dev/null +++ b/src/lib/chat/AgentTokenMenu.svelte @@ -0,0 +1,161 @@ + + + + + diff --git a/src/lib/chat/AgentTokenMenu.test.svelte.ts b/src/lib/chat/AgentTokenMenu.test.svelte.ts new file mode 100644 index 0000000..d947078 --- /dev/null +++ b/src/lib/chat/AgentTokenMenu.test.svelte.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/svelte"; +import AgentTokenMenu from "./AgentTokenMenu.svelte"; +import type { AgentProfile } from "../daemon/types"; + +function makeProfile(overrides: Partial = {}): AgentProfile { + return { + id: "profile-1", + handle: "reviewer", + name: "Reviewer", + description: "Reviews changes", + runtime: "codex", + skills: [], + prompt: "Review changes.", + archived_at: null, + avatar_asset_path: null, + avatar_status: "pending", + avatar_error: null, + active_version_id: "version-1", + created_at: 1, + updated_at: 1, + ...overrides, + }; +} + +describe("AgentTokenMenu", () => { + it("selects a shadow route token for an active matching profile", async () => { + const onSelect = vi.fn(); + render(AgentTokenMenu, { + kind: "shadow", + query: "rev", + profiles: [makeProfile()], + onSelect, + }); + + await fireEvent.click(screen.getByRole("option", { name: /%reviewer/i })); + + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ + kind: "shadow", + profileId: "profile-1", + handle: "reviewer", + })); + }); + + it("filters archived profiles and matches by name", () => { + render(AgentTokenMenu, { + kind: "reusable", + query: "design", + profiles: [ + makeProfile({ id: "profile-1", handle: "reviewer", name: "Reviewer" }), + makeProfile({ id: "profile-2", handle: "designer", name: "Design Partner" }), + makeProfile({ id: "profile-3", handle: "design-archive", name: "Design Archive", archived_at: 10 }), + ], + onSelect: vi.fn(), + }); + + expect(screen.getByRole("option", { name: /@designer/i })).toBeTruthy(); + expect(screen.queryByRole("option", { name: /@design-archive/i })).toBeNull(); + }); + + it("supports keyboard selection and escape close", async () => { + const onSelect = vi.fn(); + const onClose = vi.fn(); + render(AgentTokenMenu, { + kind: "reusable", + query: "", + profiles: [ + makeProfile({ id: "profile-1", handle: "reviewer", name: "Reviewer" }), + makeProfile({ id: "profile-2", handle: "zdebugger", name: "Debugger" }), + ], + onSelect, + onClose, + }); + + const listbox = screen.getByRole("listbox", { name: "Agent token suggestions" }); + await fireEvent.keyDown(listbox, { key: "ArrowDown" }); + await fireEvent.keyDown(listbox, { key: "Enter" }); + + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ + kind: "reusable", + profileId: "profile-2", + handle: "zdebugger", + })); + + await fireEvent.keyDown(listbox, { key: "Escape" }); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/src/lib/chat/ChatInput.svelte b/src/lib/chat/ChatInput.svelte index 34efe46..c98b3ba 100644 --- a/src/lib/chat/ChatInput.svelte +++ b/src/lib/chat/ChatInput.svelte @@ -1,22 +1,52 @@ + +
+
+
+

Metrics

+

Cost and runtime facts for this chat.

+
+ {#if loading} + loading + {/if} +
+ + {#if error} + + {/if} + +
+
+ Tokens + {formatMetricValue(totalTokens, "tokens")} +
+
+ Turns + {formatMetricValue(metrics?.turn_count, "turns")} +
+
+ Tools + {formatMetricValue(metrics?.tool_call_count, "tools")} +
+
+ Errors + {formatMetricValue(metrics?.error_count, "errors")} +
+
+ Elapsed + {formatDurationMs(metrics?.total_elapsed_ms)} +
+
+ Slowest + {formatDurationMs(metrics?.slowest_turn_ms)} +
+
+ +
+
+ Agent + Status + Turns + Tokens + Tools + Outbox + Errors +
+ {#each agentRows() as row (row.sessionId)} + + {:else} +

No linked agents yet.

+ {/each} +
+ +
+

Turn Metrics

+ {formatDurationMs(metrics?.average_turn_ms)} average +
+
+
+ Turn + Agent + Status + Duration + Tokens + Tools + Outbox + Errors +
+ {#each turnRows() as row (row.turn_id)} +
+ {row.turn_id} + {turnAgentName(row)} + {row.status} + {formatDurationMs(row.duration_ms)} + {formatMetricValue(row.total_tokens, "tokens")} + {formatMetricValue(row.tool_call_count, "tools")} + {formatMetricValue(row.outbox_write_count, "outbox")} + {formatMetricValue(row.error_count, "errors")} +
+ {:else} +

No turns recorded for this chat.

+ {/each} +
+
+ + diff --git a/src/lib/chat/ChatMetricsTab.test.svelte.ts b/src/lib/chat/ChatMetricsTab.test.svelte.ts new file mode 100644 index 0000000..ecb31ac --- /dev/null +++ b/src/lib/chat/ChatMetricsTab.test.svelte.ts @@ -0,0 +1,358 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, fireEvent } from "@testing-library/svelte"; +import ChatMetricsTab from "./ChatMetricsTab.svelte"; +import type { AgentTurnTrace, ChatAgentLink, ChatMetrics, DaemonSession } from "../daemon/types"; + +const metrics: ChatMetrics = { + chat_id: "chat-1", + turn_count: 2, + input_tokens: 0, + output_tokens: 20, + cache_read_tokens: 0, + cache_write_tokens: 0, + tool_call_count: 1, + outbox_write_count: 1, + error_count: 0, + total_elapsed_ms: 800, + average_turn_ms: 800, + slowest_turn_ms: 800, + updated_at: 10, + agents: [ + { + session_id: "session-1", + profile_id: "profile-1", + profile_version_id: "version-1", + route_type: "reusable", + focused: true, + token_source: "@reviewer", + status: "running", + turn_count: 1, + input_tokens: 0, + output_tokens: 20, + cache_read_tokens: 0, + cache_write_tokens: 0, + total_tokens: 20, + tool_call_count: 1, + outbox_write_count: 1, + error_count: 0, + total_elapsed_ms: 800, + average_turn_ms: 800, + slowest_turn_ms: 800, + current_turn_id: "turn-1", + updated_at: 4, + }, + { + session_id: "session-2", + profile_id: "profile-2", + profile_version_id: "version-2", + route_type: "shadow", + focused: false, + token_source: "%debugger", + status: "running", + turn_count: 1, + input_tokens: null, + output_tokens: null, + cache_read_tokens: null, + cache_write_tokens: null, + total_tokens: null, + tool_call_count: null, + outbox_write_count: null, + error_count: null, + total_elapsed_ms: null, + average_turn_ms: null, + slowest_turn_ms: null, + current_turn_id: "turn-2", + updated_at: 5, + }, + ], + turns: [ + { + turn_id: "turn-2", + session_id: "session-2", + chat_id: "chat-1", + chat_message_id: "message-1", + status: "active", + received_at: 5, + activity_started_at: null, + ended_at: null, + activity_latency_ms: null, + duration_ms: null, + input_tokens: null, + output_tokens: null, + cache_read_tokens: null, + cache_write_tokens: null, + total_tokens: null, + tool_call_count: null, + outbox_write_count: null, + error_count: null, + updated_at: 5, + }, + { + turn_id: "turn-1", + session_id: "session-1", + chat_id: "chat-1", + chat_message_id: "message-1", + status: "completed", + received_at: 1, + activity_started_at: 2, + ended_at: 802, + activity_latency_ms: 1, + duration_ms: 801, + input_tokens: 0, + output_tokens: 20, + cache_read_tokens: 0, + cache_write_tokens: 0, + total_tokens: 20, + tool_call_count: 1, + outbox_write_count: 1, + error_count: 0, + updated_at: 4, + }, + ], + workspace_links: [], +}; + +const agentLinks: ChatAgentLink[] = [ + { + id: "link-1", + chat_id: "chat-1", + session_id: "session-1", + profile_id: "profile-1", + profile_version_id: "version-1", + route_type: "reusable", + focused: true, + token_source: "@reviewer", + created_at: 1, + }, + { + id: "link-2", + chat_id: "chat-1", + session_id: "session-2", + profile_id: "profile-2", + profile_version_id: "version-2", + route_type: "shadow", + focused: false, + token_source: "%debugger", + created_at: 2, + }, +]; + +const sessions = new Map([ + ["session-1", { + id: "session-1", + label: "Reviewer", + agent: "codex", + cwd: "/repo", + args: [], + status: "running", + native_session_id: null, + pid: 100, + created_at: 1, + updated_at: 2, + ended_at: null, + end_reason: null, + }], +]); + +const traces = new Map([ + ["session-1", [ + { + turn: { + id: "turn-1", + session_id: "session-1", + chat_id: "chat-1", + chat_message_id: "message-1", + inbox_seq: 1, + status: "completed", + received_at: 1, + activity_started_at: 2, + ended_at: 3, + }, + metrics: { + turn_id: "turn-1", + input_tokens: 0, + output_tokens: 20, + cache_read_tokens: 0, + cache_write_tokens: 0, + tool_call_count: 1, + outbox_write_count: 1, + error_count: 0, + updated_at: 4, + }, + events: [], + }, + ]], + ["session-2", [ + { + turn: { + id: "turn-2", + session_id: "session-2", + chat_id: "chat-1", + chat_message_id: "message-1", + inbox_seq: 2, + status: "received", + received_at: 5, + activity_started_at: null, + ended_at: null, + }, + metrics: null, + events: [], + }, + ]], +]); + +describe("ChatMetricsTab", () => { + it("shows chat totals and keeps unavailable per-agent metrics distinct from zero", () => { + const { getAllByText, getByText } = render(ChatMetricsTab, { + metrics, + agentLinks, + sessions, + profiles: new Map([ + ["profile-1", { id: "profile-1", handle: "reviewer", name: "Reviewer" }], + ["profile-2", { id: "profile-2", handle: "debugger", name: "Debugger" }], + ]), + traces, + onOpenAgent: vi.fn(), + }); + + expect(getAllByText("20 tokens").length).toBeGreaterThan(0); + expect(getByText("2 turns")).toBeTruthy(); + expect(getAllByText("1 tool").length).toBeGreaterThan(0); + expect(getByText("Elapsed")).toBeTruthy(); + expect(getAllByText("800ms").length).toBeGreaterThan(0); + expect(getByText("Slowest")).toBeTruthy(); + expect(getAllByText("Reviewer").length).toBeGreaterThan(0); + expect(getByText("@reviewer")).toBeTruthy(); + expect(getAllByText("running").length).toBeGreaterThan(0); + expect(getAllByText("Debugger").length).toBeGreaterThan(0); + expect(getByText("%debugger")).toBeTruthy(); + expect(getAllByText("unavailable").length).toBeGreaterThan(0); + expect(getByText("Turn Metrics")).toBeTruthy(); + expect(getByText("turn-2")).toBeTruthy(); + expect(getByText("active")).toBeTruthy(); + expect(getByText("completed")).toBeTruthy(); + expect(getAllByText("1 outbox write").length).toBeGreaterThan(0); + }); + + it("opens the agent observability workspace from an agent row", async () => { + const onOpenAgent = vi.fn(); + const { getByRole } = render(ChatMetricsTab, { + metrics, + agentLinks, + sessions, + profiles: new Map([["profile-1", { id: "profile-1", handle: "reviewer", name: "Reviewer" }]]), + traces, + onOpenAgent, + }); + + await fireEvent.click(getByRole("button", { name: /open reviewer trace/i })); + + expect(onOpenAgent).toHaveBeenCalledWith("session-1"); + }); + + it("does not show stale fallback trace rows from another chat", () => { + const emptyMetrics: ChatMetrics = { + ...metrics, + chat_id: "chat-empty", + turn_count: 0, + input_tokens: 0, + output_tokens: 0, + cache_read_tokens: 0, + cache_write_tokens: 0, + tool_call_count: 0, + outbox_write_count: 0, + error_count: 0, + total_elapsed_ms: null, + average_turn_ms: null, + slowest_turn_ms: null, + agents: [], + turns: [], + }; + const { getByText, queryByText } = render(ChatMetricsTab, { + metrics: emptyMetrics, + agentLinks: [], + sessions, + profiles: new Map(), + traces, + onOpenAgent: vi.fn(), + }); + + expect(getByText("No turns recorded for this chat.")).toBeTruthy(); + expect(queryByText("turn-1")).toBeNull(); + expect(queryByText("turn-2")).toBeNull(); + }); + + it("filters reusable-agent fallback traces by the linked chat while metrics load", () => { + const mixedChatTraces = new Map([ + ["session-1", [ + { + turn: { + id: "turn-current", + session_id: "session-1", + chat_id: "chat-1", + chat_message_id: "message-1", + inbox_seq: 1, + status: "completed", + received_at: 1, + activity_started_at: 2, + ended_at: 3, + }, + metrics: { + turn_id: "turn-current", + input_tokens: 0, + output_tokens: 20, + cache_read_tokens: 0, + cache_write_tokens: 0, + tool_call_count: 1, + outbox_write_count: 0, + error_count: 0, + updated_at: 4, + }, + events: [], + }, + { + turn: { + id: "turn-stale", + session_id: "session-1", + chat_id: "chat-other", + chat_message_id: "message-other", + inbox_seq: 2, + status: "completed", + received_at: 5, + activity_started_at: 6, + ended_at: 7, + }, + metrics: { + turn_id: "turn-stale", + input_tokens: 0, + output_tokens: 999, + cache_read_tokens: 0, + cache_write_tokens: 0, + tool_call_count: 9, + outbox_write_count: 0, + error_count: 0, + updated_at: 8, + }, + events: [], + }, + ]], + ]); + + const { getAllByText, getByText, queryByText } = render(ChatMetricsTab, { + metrics: null, + agentLinks: [agentLinks[0]], + sessions, + profiles: new Map([["profile-1", { id: "profile-1", handle: "reviewer", name: "Reviewer" }]]), + traces: mixedChatTraces, + loading: true, + onOpenAgent: vi.fn(), + }); + + expect(getByText("turn-current")).toBeTruthy(); + expect(getAllByText("1 turn").length).toBeGreaterThan(0); + expect(getAllByText("20 tokens").length).toBeGreaterThan(0); + expect(queryByText("turn-stale")).toBeNull(); + expect(queryByText("999 tokens")).toBeNull(); + expect(queryByText("1019 tokens")).toBeNull(); + }); +}); diff --git a/src/lib/chat/ChatSessionList.svelte b/src/lib/chat/ChatSessionList.svelte index 36ccb48..e888856 100644 --- a/src/lib/chat/ChatSessionList.svelte +++ b/src/lib/chat/ChatSessionList.svelte @@ -9,16 +9,19 @@ onProjectFocus, onNewChat, onSelect, + onSelectChat, }: { currentFocus: FocusTarget; onProjectFocus: (projectId: string) => void; onNewChat: (projectId: string) => void; onSelect: (sessionId: string) => void; + onSelectChat?: (chatId: string) => void; } = $props(); const projectsState = fromStore(projectsStore); const projectList = $derived(projectsState.current); const sessionsList = $derived([...daemonStore.sessions.values()]); + const chatsList = $derived([...daemonStore.chats.values()]); const groups = $derived(groupSessionsByProject(projectList, sessionsList)); @@ -46,6 +49,18 @@ {s.agent} {/each} + {#each chatsList.filter((chat) => chat.project_id === p.id) as chat (chat.id)} + + {/each} {/each} {#if groups.other.length} diff --git a/src/lib/chat/ChatSummaryPane.svelte b/src/lib/chat/ChatSummaryPane.svelte new file mode 100644 index 0000000..7ab5b9d --- /dev/null +++ b/src/lib/chat/ChatSummaryPane.svelte @@ -0,0 +1,104 @@ + + +
+ {#if isEmpty} + none + {:else} +
+ agents + {#each agents as agent (agent.id)} + {agentLabel(agent)} + {/each} + {#if focusedAgent} + focused {agentLabel(focusedAgent)} + {/if} +
+ +
+ workspaces + {#each workspaces as workspace (workspace.id)} + {workspace.label} + {/each} + {#if focusedWorkspace} + focused {focusedWorkspace.label} + {/if} +
+ {/if} +
+ + diff --git a/src/lib/chat/ChatSummaryPane.test.svelte.ts b/src/lib/chat/ChatSummaryPane.test.svelte.ts new file mode 100644 index 0000000..f20e578 --- /dev/null +++ b/src/lib/chat/ChatSummaryPane.test.svelte.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/svelte"; +import ChatSummaryPane from "./ChatSummaryPane.svelte"; + +describe("ChatSummaryPane", () => { + it("shows linked agents, focused agent, workspaces, and focused workspace", () => { + render(ChatSummaryPane, { + agents: [ + { id: "agent-1", handle: "reviewer", name: "Reviewer", focused: true }, + { id: "agent-2", handle: "debugger", name: "Debugger", focused: false }, + ], + workspaces: [ + { id: "workspace-1", label: "Controller", path: "/repo/controller", focused: false }, + { id: "workspace-2", label: "Docs", path: "/repo/docs", focused: true }, + ], + }); + + expect(screen.getByText("agents")).toBeTruthy(); + expect(screen.getByText("@reviewer")).toBeTruthy(); + expect(screen.getByText("@debugger")).toBeTruthy(); + expect(screen.getByText("focused @reviewer")).toBeTruthy(); + expect(screen.getByText("workspaces")).toBeTruthy(); + expect(screen.getByText("Controller")).toBeTruthy(); + expect(screen.getByText("Docs")).toBeTruthy(); + expect(screen.getByText("focused Docs")).toBeTruthy(); + }); + + it("renders a quiet none state when nothing is known locally", () => { + render(ChatSummaryPane, { agents: [], workspaces: [] }); + + expect(screen.getByText("none")).toBeTruthy(); + }); +}); diff --git a/src/lib/chat/ChatView.svelte b/src/lib/chat/ChatView.svelte index b424b8f..370cfc9 100644 --- a/src/lib/chat/ChatView.svelte +++ b/src/lib/chat/ChatView.svelte @@ -1,21 +1,224 @@ -{#if session} +{#if chat && chatId} +
+
+ {chat.title} + chat +
+
+ + +
+ {#if activeChatTab === "metrics"} + + {:else} + + + + {/if} +
+{:else if session && sessionId}
{session.label} {session.agent} {session.status}
- - + +
{:else} -

Session not found.

+

{chatId ? "Chat not found." : "Session not found."}

{/if} diff --git a/src/lib/chat/ChatView.test.svelte.ts b/src/lib/chat/ChatView.test.svelte.ts index edb5bc0..4d6ad99 100644 --- a/src/lib/chat/ChatView.test.svelte.ts +++ b/src/lib/chat/ChatView.test.svelte.ts @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, waitFor } from "@testing-library/svelte"; +import { render, fireEvent, waitFor } from "@testing-library/svelte"; +import { get } from "svelte/store"; +import { workspaceMode } from "../stores"; const openStreamMock = vi.hoisted(() => vi.fn(() => ({ close: vi.fn() }))); @@ -7,10 +9,112 @@ vi.mock("../daemon/store.svelte", () => import("./__mocks__/daemonStore.svelte") vi.mock("../daemon/stream", () => ({ openStream: openStreamMock })); import ChatView from "./ChatView.svelte"; -import { readEvents } from "./__mocks__/daemonStore.svelte"; +import { + daemonStore, + loadAgentTrace, + loadChatMetrics, + readChatTranscript, + readEvents, + sendChatMessage, +} from "./__mocks__/daemonStore.svelte"; + +function makeChat(id: string, title: string) { + return { + id, + project_id: "proj-1", + title, + created_at: 1, + updated_at: 1, + deleted_at: null, + }; +} + +function makeChatMessage(id: string, chatId: string, body: string) { + return { + id, + chat_id: chatId, + idempotency_id: null, + body, + token_spans: [], + created_at: 1, + }; +} + +function makeProfile() { + return { + id: "profile-1", + handle: "reviewer", + name: "Reviewer", + description: "Reviews changes", + runtime: "codex", + skills: [], + prompt: "Review changes.", + archived_at: null, + avatar_asset_path: null, + avatar_status: "pending", + avatar_error: null, + active_version_id: "version-1", + created_at: 1, + updated_at: 1, + }; +} + +function makeWorkspaceLink() { + return { + id: "workspace-link-1", + chat_id: "chat-1", + project_id: "proj-1", + workspace_id: "workspace-1", + path: "/repo/controller", + label: "controller-routing", + branch: "codex/routing", + focused: true, + created_at: 1, + updated_at: 1, + }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} describe("ChatView", () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => { + vi.clearAllMocks(); + daemonStore.client = { + readEvents, + readChatTranscript, + sendChatMessage, + wsUrl: () => "ws://x", + chatStreamUrl: () => "ws://chat", + }; + daemonStore.sessions.clear(); + daemonStore.sessions.set("s1", { id: "s1", label: "Chat 1", agent: "claude", status: "running" }); + daemonStore.transcripts.clear(); + daemonStore.activeSessionId = "s1"; + daemonStore.chats.clear(); + daemonStore.chatTranscripts.clear(); + daemonStore.chatAgentLinks.clear(); + daemonStore.chatWorkspaceLinks.clear(); + daemonStore.chatSummaries.clear(); + daemonStore.agentTraces.clear(); + daemonStore.profiles.clear(); + daemonStore.profiles.set("profile-1", makeProfile()); + daemonStore.activeChatId = null; + daemonStore.activeSessionId = null; + workspaceMode.set("chat"); + readChatTranscript.mockResolvedValue([]); + sendChatMessage.mockResolvedValue({ + message: makeChatMessage("msg-1", "chat-1", "hello"), + turns: [], + }); + }); it("hydrates from readEvents and renders the agent text", async () => { readEvents.mockResolvedValueOnce([ @@ -26,4 +130,203 @@ describe("ChatView", () => { render(ChatView, { sessionId: "s1" }); await waitFor(() => expect(openStreamMock).toHaveBeenCalledWith("s1")); }); + + it("loads a new chat transcript when the chat id changes", async () => { + daemonStore.chats.set("chat-a", makeChat("chat-a", "Chat A")); + daemonStore.chats.set("chat-b", makeChat("chat-b", "Chat B")); + + const { rerender } = render(ChatView, { chatId: "chat-a" }); + await waitFor(() => expect(readChatTranscript).toHaveBeenCalledWith("chat-a")); + + await rerender({ chatId: "chat-b" }); + + await waitFor(() => expect(readChatTranscript).toHaveBeenCalledWith("chat-b")); + }); + + it("keeps optimistic chat messages when the initial transcript read resolves later", async () => { + const initialRead = deferred(); + readChatTranscript.mockReturnValueOnce(initialRead.promise); + sendChatMessage.mockResolvedValueOnce({ + message: makeChatMessage("msg-optimistic", "chat-1", "ask %reviewer optimistic hello"), + turns: [], + }); + daemonStore.chats.set("chat-1", makeChat("chat-1", "Chat 1")); + + const { findByRole, findByText, getByRole } = render(ChatView, { chatId: "chat-1" }); + const ta = getByRole("textbox") as HTMLTextAreaElement; + + await fireEvent.input(ta, { target: { value: "ask %rev optimistic hello" } }); + ta.setSelectionRange(8, 8); + await fireEvent.keyUp(ta, { key: "v" }); + await fireEvent.click(await findByRole("option", { name: /%reviewer/i })); + await fireEvent.keyDown(ta, { key: "Enter", metaKey: true }); + expect(await findByText("ask %reviewer optimistic hello")).toBeTruthy(); + + initialRead.resolve([]); + + await waitFor(() => expect(readChatTranscript).toHaveBeenCalledWith("chat-1")); + expect(await findByText("ask %reviewer optimistic hello")).toBeTruthy(); + }); + + it("renders the chat summary from known agent and workspace links", async () => { + daemonStore.chats.set("chat-1", makeChat("chat-1", "Chat 1")); + daemonStore.profiles.set("profile-2", { + ...makeProfile(), + id: "profile-2", + handle: "debugger", + name: "Debugger", + }); + daemonStore.chatTranscripts.set("chat-1", [ + { + type: "user_message", + message: { + ...makeChatMessage("msg-1", "chat-1", "ask @reviewer %debugger"), + token_spans: [ + { kind: "reusable", handle: "reviewer", start: 4, end: 13 }, + { kind: "shadow", handle: "debugger", start: 14, end: 23 }, + ], + }, + }, + ]); + daemonStore.chatWorkspaceLinks.set("chat-1", [makeWorkspaceLink()]); + + const { findByText } = render(ChatView, { chatId: "chat-1" }); + + expect(await findByText("@reviewer")).toBeTruthy(); + expect(await findByText("%debugger")).toBeTruthy(); + expect(await findByText("focused @reviewer")).toBeTruthy(); + expect(await findByText("controller-routing")).toBeTruthy(); + expect(await findByText("focused controller-routing")).toBeTruthy(); + }); + + it("renders linked agents from known chat agent links", async () => { + daemonStore.chats.set("chat-1", makeChat("chat-1", "Chat 1")); + daemonStore.chatAgentLinks.set("chat-1", [ + { + id: "agent-link-1", + chat_id: "chat-1", + session_id: "session-1", + profile_id: "profile-1", + profile_version_id: "version-1", + route_type: "reusable", + focused: true, + token_source: "@reviewer", + created_at: 1, + }, + ]); + + const { findByText } = render(ChatView, { chatId: "chat-1" }); + + expect(await findByText("@reviewer")).toBeTruthy(); + expect(await findByText("focused @reviewer")).toBeTruthy(); + }); + + it("marks the latest reusable token as the focused summary agent", async () => { + daemonStore.chats.set("chat-1", makeChat("chat-1", "Chat 1")); + daemonStore.profiles.set("profile-2", { + ...makeProfile(), + id: "profile-2", + handle: "planner", + name: "Planner", + }); + daemonStore.chatTranscripts.set("chat-1", [ + { + type: "user_message", + message: { + ...makeChatMessage("msg-1", "chat-1", "ask @planner @reviewer"), + token_spans: [ + { kind: "reusable", handle: "planner", start: 4, end: 12 }, + { kind: "reusable", handle: "reviewer", start: 13, end: 22 }, + ], + }, + }, + ]); + + const { findByText, queryByText } = render(ChatView, { chatId: "chat-1" }); + + expect(await findByText("focused @reviewer")).toBeTruthy(); + expect(queryByText("focused @planner")).toBeNull(); + }); + + it("loads chat metrics and opens an agent trace from the metrics tab", async () => { + daemonStore.chats.set("chat-1", makeChat("chat-1", "Chat 1")); + daemonStore.chatAgentLinks.set("chat-1", [ + { + id: "agent-link-1", + chat_id: "chat-1", + session_id: "session-1", + profile_id: "profile-1", + profile_version_id: "version-1", + route_type: "reusable", + focused: true, + token_source: "@reviewer", + created_at: 1, + }, + ]); + daemonStore.sessions.set("session-1", { + id: "session-1", + label: "Reviewer", + agent: "codex", + cwd: "/repo", + args: [], + status: "running", + native_session_id: null, + pid: null, + created_at: 1, + updated_at: 1, + ended_at: null, + end_reason: null, + }); + daemonStore.chatSummaries.set("chat-1", { + chat_id: "chat-1", + turn_count: 1, + input_tokens: 0, + output_tokens: 20, + cache_read_tokens: 0, + cache_write_tokens: 0, + tool_call_count: 1, + outbox_write_count: 1, + error_count: 0, + updated_at: 1, + }); + daemonStore.agentTraces.set("session-1", [ + { + turn: { + id: "turn-1", + session_id: "session-1", + chat_id: "chat-1", + chat_message_id: "message-1", + inbox_seq: 1, + status: "completed", + received_at: 1, + activity_started_at: 2, + ended_at: 3, + }, + metrics: { + turn_id: "turn-1", + input_tokens: 0, + output_tokens: 20, + cache_read_tokens: 0, + cache_write_tokens: 0, + tool_call_count: 1, + outbox_write_count: 1, + error_count: 0, + updated_at: 4, + }, + events: [], + }, + ]); + + const { findByRole, findAllByText, getByRole } = render(ChatView, { chatId: "chat-1" }); + await fireEvent.click(await findByRole("tab", { name: "Metrics" })); + + await waitFor(() => expect(loadChatMetrics).toHaveBeenCalledWith("chat-1")); + await waitFor(() => expect(loadAgentTrace).toHaveBeenCalledWith("session-1")); + expect((await findAllByText("20 tokens")).length).toBeGreaterThan(0); + + await fireEvent.click(getByRole("button", { name: /open reviewer trace/i })); + + expect(daemonStore.activeSessionId).toBe("session-1"); + expect(get(workspaceMode)).toBe("agent-observe"); + }); }); diff --git a/src/lib/chat/ChatWorkspace.svelte b/src/lib/chat/ChatWorkspace.svelte index 4ae2a5f..f9791bd 100644 --- a/src/lib/chat/ChatWorkspace.svelte +++ b/src/lib/chat/ChatWorkspace.svelte @@ -1,37 +1,93 @@ {#if !daemonStore.reachable} +{:else if daemonStore.activeChatId} + {:else if daemonStore.activeSessionId} {:else}
Select or create a chat.
{/if} -{#if daemonStore.newChatTarget} - +{#if createError} + {/if} diff --git a/src/lib/chat/ChatWorkspace.test.ts b/src/lib/chat/ChatWorkspace.test.ts index 79f2b2e..ee7bb1d 100644 --- a/src/lib/chat/ChatWorkspace.test.ts +++ b/src/lib/chat/ChatWorkspace.test.ts @@ -1,32 +1,78 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, fireEvent } from "@testing-library/svelte"; - -vi.mock("../daemon/store.svelte", () => { - const daemonStore = { - token: null as string | null, - reachable: false, - client: null as any, - sessions: new Map(), - transcripts: new Map(), - activeSessionId: null as string | null, - }; +import { render, fireEvent, waitFor } from "@testing-library/svelte"; +import { projects, focusTarget, workspaceMode, hotkeyAction, type Project } from "$lib/stores"; + +vi.mock("../daemon/store.svelte", () => import("./__mocks__/daemonStore.svelte")); + +vi.mock("../daemon/stream", () => ({ + openStream: vi.fn(() => ({ close: vi.fn() })), +})); + +function makeProject(id: string, name: string, repoPath: string): Project { return { - daemonStore, - bootstrap: vi.fn(async () => {}), - pingDaemon: vi.fn(async () => {}), - loadSessions: vi.fn(async () => {}), + id, + name, + repo_path: repoPath, + created_at: "2026-01-01", + archived: false, + maintainer: { enabled: false, interval_minutes: 60 }, + auto_worker: { enabled: false }, + sessions: [], + prompts: [], + staged_sessions: [], }; -}); +} + +function pressKey(key: string) { + window.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })); +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} describe("ChatWorkspace", () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(async () => { + vi.clearAllMocks(); + const { daemonStore, readChatTranscript } = await import("./__mocks__/daemonStore.svelte"); + daemonStore.reachable = true; + daemonStore.client = { + readEvents: vi.fn(async () => []), + readChatTranscript, + createChat: vi.fn(), + sendMessage: vi.fn(), + sendChatMessage: vi.fn(), + wsUrl: () => "ws://x", + chatStreamUrl: () => "ws://chat", + }; + daemonStore.sessions.clear(); + daemonStore.transcripts.clear(); + daemonStore.activeSessionId = null; + daemonStore.chats.clear(); + daemonStore.chatTranscripts.clear(); + daemonStore.activeChatId = null; + daemonStore.newChatTarget = null; + readChatTranscript.mockResolvedValue([]); + projects.set([]); + focusTarget.set(null); + workspaceMode.set("chat"); + hotkeyAction.set(null); + }); it("renders empty state when daemon unreachable", async () => { const { daemonStore } = await import("../daemon/store.svelte"); (daemonStore as any).reachable = false; const ChatWorkspace = (await import("./ChatWorkspace.svelte")).default; - const { findByText } = render(ChatWorkspace); + const { findByText, queryByText } = render(ChatWorkspace); expect(await findByText(/Daemon not running/)).toBeTruthy(); + expect(await findByText(/\/api\/daemon/)).toBeTruthy(); + expect(queryByText(/Expected token|daemon\.token/)).toBeNull(); }); it("clicking Retry calls pingDaemon", async () => { @@ -37,4 +83,66 @@ describe("ChatWorkspace", () => { await fireEvent.click(getByText("Retry")); expect(pingDaemon).toHaveBeenCalled(); }); + + it("pressing n creates a chat for the focused project and focuses the composer", async () => { + const { daemonStore } = await import("../daemon/store.svelte"); + const createChat = vi.fn(async () => ({ + id: "chat-1", + project_id: "proj-1", + title: "New chat", + created_at: 1, + updated_at: 1, + deleted_at: null, + })); + (daemonStore as any).client.createChat = createChat; + projects.set([makeProject("proj-1", "Controller", "/tmp/controller")]); + focusTarget.set({ type: "project", projectId: "proj-1" }); + + const ChatWorkspace = (await import("./ChatWorkspace.svelte")).default; + const HotkeyManager = (await import("../HotkeyManager.svelte")).default; + const { queryByRole, getByRole } = render(ChatWorkspace); + render(HotkeyManager); + + pressKey("n"); + + await waitFor(() => expect(createChat).toHaveBeenCalledWith({ + project_id: "proj-1", + title: "New chat", + })); + await waitFor(() => expect((daemonStore as any).activeChatId).toBe("chat-1")); + await waitFor(() => expect(document.activeElement).toBe(getByRole("textbox"))); + expect(queryByRole("dialog")).toBeNull(); + }); + + it("does not replay a second new chat request after the first create resolves", async () => { + const { daemonStore } = await import("../daemon/store.svelte"); + const firstCreate = deferred(); + const createChat = vi.fn(() => firstCreate.promise); + (daemonStore as any).client.createChat = createChat; + projects.set([makeProject("proj-1", "Controller", "/tmp/controller")]); + focusTarget.set({ type: "project", projectId: "proj-1" }); + + const ChatWorkspace = (await import("./ChatWorkspace.svelte")).default; + const HotkeyManager = (await import("../HotkeyManager.svelte")).default; + render(ChatWorkspace); + render(HotkeyManager); + + pressKey("n"); + await waitFor(() => expect(createChat).toHaveBeenCalledTimes(1)); + pressKey("n"); + expect(createChat).toHaveBeenCalledTimes(1); + + firstCreate.resolve({ + id: "chat-1", + project_id: "proj-1", + title: "New chat", + created_at: 1, + updated_at: 1, + deleted_at: null, + }); + + await waitFor(() => expect((daemonStore as any).activeChatId).toBe("chat-1")); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(createChat).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/lib/chat/DaemonEmptyState.svelte b/src/lib/chat/DaemonEmptyState.svelte index 28836a5..416abbb 100644 --- a/src/lib/chat/DaemonEmptyState.svelte +++ b/src/lib/chat/DaemonEmptyState.svelte @@ -6,7 +6,7 @@

Daemon not running

Start it with:

./target/release/the-controller-daemon
-

Expected token: ~/.the-controller/daemon.token

+

Daemon should be reachable through /api/daemon.

diff --git a/src/lib/chat/__mocks__/daemonStore.svelte.ts b/src/lib/chat/__mocks__/daemonStore.svelte.ts index 85a6dd8..f37c526 100644 --- a/src/lib/chat/__mocks__/daemonStore.svelte.ts +++ b/src/lib/chat/__mocks__/daemonStore.svelte.ts @@ -2,13 +2,40 @@ import { vi } from "vitest"; import { SvelteMap } from "svelte/reactivity"; export const readEvents = vi.fn(); +export const createChat = vi.fn(); +export const readChatTranscript = vi.fn(); +export const sendMessage = vi.fn(async () => ({ seq: 1 })); +export const sendChatMessage = vi.fn(); +export const bootstrap = vi.fn(async () => {}); +export const pingDaemon = vi.fn(async () => {}); +export const loadSessions = vi.fn(async () => {}); +export const loadProfiles = vi.fn(async () => {}); +export const loadChats = vi.fn(async () => {}); +export const loadChatLinks = vi.fn(async () => {}); +export const loadChatMetrics = vi.fn(async () => {}); +export const loadAgentTrace = vi.fn(async () => {}); export const daemonStore = $state({ - client: { readEvents, wsUrl: () => "ws://x" }, + client: { + readEvents, + readChatTranscript, + createChat, + sendMessage, + sendChatMessage, + wsUrl: () => "ws://x", + chatStreamUrl: () => "ws://chat", + }, sessions: new SvelteMap([["s1", { id: "s1", label: "Chat 1", agent: "claude", status: "running" }]]), transcripts: new SvelteMap(), activeSessionId: "s1", - token: "TOK", + profiles: new SvelteMap(), + chats: new SvelteMap(), + chatTranscripts: new SvelteMap(), + chatAgentLinks: new SvelteMap(), + chatWorkspaceLinks: new SvelteMap(), + chatSummaries: new SvelteMap(), + agentTraces: new SvelteMap(), + activeChatId: null, reachable: true, newChatTarget: null, }); diff --git a/src/lib/chat/chat-routing.test.ts b/src/lib/chat/chat-routing.test.ts new file mode 100644 index 0000000..70aecd3 --- /dev/null +++ b/src/lib/chat/chat-routing.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + extractRouteTokenQuery, + insertRouteToken, + reconcileRouteTokens, + routeTokenForProfile, +} from "./chat-routing"; + +describe("extractRouteTokenQuery", () => { + it("detects @ and % token queries at the cursor", () => { + expect(extractRouteTokenQuery("ask @rev", 8)).toEqual({ + kind: "reusable", + query: "rev", + start: 4, + end: 8, + }); + expect(extractRouteTokenQuery("ask %debug", 10)).toEqual({ + kind: "shadow", + query: "debug", + start: 4, + end: 10, + }); + }); +}); + +describe("insertRouteToken", () => { + it("replaces the current token query and returns durable metadata", () => { + expect( + insertRouteToken("ask @rev now", { kind: "reusable", query: "rev", start: 4, end: 8 }, "reviewer"), + ).toEqual({ + text: "ask @reviewer now", + cursor: 13, + token: { kind: "reusable", handle: "reviewer", start: 4, end: 13 }, + }); + }); +}); + +describe("routeTokenForProfile", () => { + it("maps a selected profile to an outbound route token", () => { + expect( + routeTokenForProfile( + { handle: "debug" }, + "shadow", + 4, + 10, + ), + ).toEqual({ kind: "shadow", handle: "debug", start: 4, end: 10 }); + }); +}); + +describe("reconcileRouteTokens", () => { + it("updates shifted token spans and drops tokens no longer present", () => { + expect( + reconcileRouteTokens("please ask @reviewer", [ + { kind: "reusable", handle: "reviewer", start: 4, end: 13 }, + { kind: "shadow", handle: "debugger", start: 14, end: 23 }, + ]), + ).toEqual([ + { kind: "reusable", handle: "reviewer", start: 11, end: 20 }, + ]); + }); + + it("drops selected tokens that were edited into longer handles", () => { + expect( + reconcileRouteTokens("ask %reviewerx", [ + { kind: "shadow", handle: "reviewer", start: 4, end: 13 }, + ]), + ).toEqual([]); + }); +}); diff --git a/src/lib/chat/chat-routing.ts b/src/lib/chat/chat-routing.ts new file mode 100644 index 0000000..472c915 --- /dev/null +++ b/src/lib/chat/chat-routing.ts @@ -0,0 +1,89 @@ +import type { AgentProfile, RouteToken, RouteTokenKind } from "../daemon/types"; + +export interface RouteTokenQuery { + kind: RouteTokenKind; + query: string; + start: number; + end: number; +} + +export interface InsertedRouteToken { + text: string; + cursor: number; + token: RouteToken; +} + +const ROUTE_TOKEN_PATTERN = /(^|\s)([@%])([A-Za-z0-9_-]*)$/; +const ROUTE_HANDLE_CHAR = /[A-Za-z0-9_-]/; + +export function extractRouteTokenQuery(text: string, cursor: number): RouteTokenQuery | null { + const prefix = text.slice(0, cursor); + const match = prefix.match(ROUTE_TOKEN_PATTERN); + if (!match) return null; + + const marker = match[2]; + const query = match[3] ?? ""; + const start = cursor - marker.length - query.length; + return { + kind: marker === "@" ? "reusable" : "shadow", + query, + start, + end: cursor, + }; +} + +export function routeTokenForProfile( + profile: Pick, + kind: RouteTokenKind, + start: number, + end: number, +): RouteToken { + return { kind, handle: profile.handle, start, end }; +} + +export function insertRouteToken( + text: string, + query: RouteTokenQuery, + handle: string, +): InsertedRouteToken { + const marker = query.kind === "reusable" ? "@" : "%"; + const inserted = `${marker}${handle}`; + const nextText = `${text.slice(0, query.start)}${inserted}${text.slice(query.end)}`; + const end = query.start + inserted.length; + return { + text: nextText, + cursor: end, + token: { kind: query.kind, handle, start: query.start, end }, + }; +} + +export function reconcileRouteTokens(text: string, tokens: RouteToken[]): RouteToken[] { + const usedStarts = new Set(); + return tokens.flatMap((token) => { + const marker = token.kind === "reusable" ? "@" : "%"; + const tokenText = `${marker}${token.handle}`; + let start = findRouteToken(text, tokenText, Math.max(0, Math.min(token.start, text.length))); + if (start === -1 && token.start > 0) { + start = findRouteToken(text, tokenText, 0); + } + while (start !== -1 && usedStarts.has(start)) { + start = findRouteToken(text, tokenText, start + tokenText.length); + } + if (start === -1) return []; + usedStarts.add(start); + return [{ ...token, start, end: start + tokenText.length }]; + }); +} + +function findRouteToken(text: string, tokenText: string, fromIndex: number): number { + let start = text.indexOf(tokenText, fromIndex); + while (start !== -1) { + const before = start === 0 ? "" : text[start - 1]; + const after = text[start + tokenText.length] ?? ""; + if ((start === 0 || /\s/.test(before)) && !ROUTE_HANDLE_CHAR.test(after)) { + return start; + } + start = text.indexOf(tokenText, start + tokenText.length); + } + return -1; +} diff --git a/src/lib/daemon/client.test.ts b/src/lib/daemon/client.test.ts index 134c63e..6742fca 100644 --- a/src/lib/daemon/client.test.ts +++ b/src/lib/daemon/client.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { DaemonClient, DaemonHttpError } from "./client"; +import type { SavedAgentProfile } from "./types"; function mockFetch(responses: Array<{ status: number; body: unknown }>) { const queue = [...responses]; @@ -17,23 +18,262 @@ function mockFetch(responses: Array<{ status: number; body: unknown }>) { describe("DaemonClient", () => { beforeEach(() => vi.restoreAllMocks()); - it("listSessions sets Authorization header and returns array", async () => { - const fetchMock = mockFetch([{ status: 200, body: [{ id: "s1", label: "x", agent: "claude" }] }]); + it("calls same-origin /api/daemon routes without exposing auth", async () => { + const fetchMock = mockFetch([{ status: 200, body: [] }]); vi.stubGlobal("fetch", fetchMock); - const c = new DaemonClient("http://127.0.0.1:4867", "TOK"); - const sessions = await c.listSessions(); - expect(sessions[0].id).toBe("s1"); + + const c = new DaemonClient("/api/daemon"); + await c.listSessions(); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; - expect(url).toBe("http://127.0.0.1:4867/sessions"); - expect((init.headers as Record).Authorization).toBe("Bearer TOK"); + expect(url).toBe("/api/daemon/sessions"); + expect(init.headers).not.toMatchObject({ Authorization: expect.any(String) }); + }); + + it("builds same-origin websocket URLs", () => { + const c = new DaemonClient("/api/daemon"); + expect(c.wsUrl("s1", 5)).toBe("/api/daemon/sessions/s1/stream?since=5"); }); it("throws DaemonHttpError on 404", async () => { vi.stubGlobal("fetch", mockFetch([{ status: 404, body: { error: "not found" } }])); - const c = new DaemonClient("http://127.0.0.1:4867", "TOK"); + const c = new DaemonClient("/api/daemon"); await expect(c.getSession("missing")).rejects.toMatchObject({ name: "DaemonHttpError", status: 404, }); }); + + it("creates chats through same-origin daemon routes", async () => { + const chat = { id: "chat-1", project_id: "project-1", title: "Plan", created_at: 1, updated_at: 1, deleted_at: null }; + const fetchMock = mockFetch([{ status: 200, body: chat }]); + vi.stubGlobal("fetch", fetchMock); + + const c = new DaemonClient("/api/daemon"); + await expect(c.createChat({ project_id: "project-1", title: "Plan" })).resolves.toEqual(chat); + + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe("/api/daemon/chats"); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toEqual({ project_id: "project-1", title: "Plan" }); + expect(init.headers).not.toMatchObject({ Authorization: expect.any(String) }); + }); + + it("lists agent profiles without auth headers", async () => { + const profiles = [ + { + id: "profile-1", + handle: "planner", + name: "Planner", + description: "Plans work", + runtime: "claude", + skills: ["planning"], + prompt: "Plan carefully", + archived_at: null, + avatar_asset_path: null, + avatar_status: "none", + avatar_error: null, + active_version_id: "version-1", + created_at: 1, + updated_at: 2, + }, + ]; + const fetchMock = mockFetch([{ status: 200, body: profiles }]); + vi.stubGlobal("fetch", fetchMock); + + const c = new DaemonClient("/api/daemon"); + await expect(c.listProfiles()).resolves.toEqual(profiles); + + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe("/api/daemon/profiles"); + expect(init.headers).not.toMatchObject({ Authorization: expect.any(String) }); + }); + + it("saves profiles as profile-version wrappers over create and update routes", async () => { + const saved = { + profile: { + id: "profile-1", + handle: "planner", + name: "Planner", + description: "Plans work", + runtime: "claude", + skills: ["planning"], + prompt: "Plan carefully", + archived_at: null, + avatar_asset_path: null, + avatar_status: "none", + avatar_error: null, + active_version_id: "version-1", + created_at: 1, + updated_at: 2, + }, + version: { + id: "version-1", + profile_id: "profile-1", + runtime: "claude", + model: "sonnet", + prompt: "Plan carefully", + skills: ["planning"], + default_workspace_behavior: "focused", + outbox_instructions: "summarize", + validation_result: null, + created_at: 3, + }, + } satisfies SavedAgentProfile; + const fetchMock = mockFetch([ + { status: 200, body: saved }, + { status: 200, body: saved }, + ]); + vi.stubGlobal("fetch", fetchMock); + + const c = new DaemonClient("/api/daemon"); + const created: SavedAgentProfile = await c.saveProfile({ + handle: "planner", + name: "Planner", + runtime: "claude", + prompt: "Plan carefully", + }); + const updated: SavedAgentProfile = await c.saveProfile({ + id: "profile-1", + name: "Planner 2", + }); + + expect(created).toEqual(saved); + expect(updated).toEqual(saved); + const [createUrl, createInit] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + const [updateUrl, updateInit] = fetchMock.mock.calls[1] as unknown as [string, RequestInit]; + expect(createUrl).toBe("/api/daemon/profiles"); + expect(createInit.method).toBe("POST"); + expect(updateUrl).toBe("/api/daemon/profiles/profile-1"); + expect(updateInit.method).toBe("PATCH"); + }); + + it("sends chat messages with route tokens", async () => { + const response = { + message: { + id: "message-1", + chat_id: "chat-1", + idempotency_id: "idem-1", + body: "@planner hello", + token_spans: [{ kind: "reusable", handle: "planner", start: 0, end: 8 }], + created_at: 3, + }, + turns: [ + { + id: "turn-1", + session_id: "session-1", + chat_id: "chat-1", + chat_message_id: "message-1", + inbox_seq: 4, + status: "queued", + received_at: 5, + activity_started_at: null, + ended_at: null, + }, + ], + }; + const fetchMock = mockFetch([{ status: 202, body: response }]); + vi.stubGlobal("fetch", fetchMock); + + const c = new DaemonClient("/api/daemon"); + await expect( + c.sendChatMessage("chat-1", { + idempotency_id: "idem-1", + body: "@planner hello", + tokens: [{ kind: "reusable", handle: "planner", start: 0, end: 8 }], + }), + ).resolves.toEqual(response); + + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe("/api/daemon/chats/chat-1/messages"); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toEqual({ + idempotency_id: "idem-1", + body: "@planner hello", + tokens: [{ kind: "reusable", handle: "planner", start: 0, end: 8 }], + }); + }); + + it("reads chat agent and workspace links", async () => { + const agentLinks = [ + { + id: "agent-link-1", + chat_id: "chat-1", + session_id: "session-1", + profile_id: "profile-1", + profile_version_id: "version-1", + route_type: "reusable", + focused: true, + token_source: "@reviewer", + created_at: 3, + }, + ]; + const workspaceLinks = [ + { + id: "workspace-link-1", + chat_id: "chat-1", + project_id: "project-1", + workspace_id: "workspace-1", + path: "/repo/controller", + label: "controller", + branch: "main", + focused: true, + created_at: 4, + updated_at: 5, + }, + ]; + const fetchMock = mockFetch([ + { status: 200, body: agentLinks }, + { status: 200, body: workspaceLinks }, + ]); + vi.stubGlobal("fetch", fetchMock); + + const c = new DaemonClient("/api/daemon"); + await expect(c.listChatAgentLinks("chat-1")).resolves.toEqual(agentLinks); + await expect(c.listChatWorkspaceLinks("chat-1")).resolves.toEqual(workspaceLinks); + + expect((fetchMock.mock.calls[0] as unknown as [string, RequestInit])[0]) + .toBe("/api/daemon/chats/chat-1/agent-links"); + expect((fetchMock.mock.calls[1] as unknown as [string, RequestInit])[0]) + .toBe("/api/daemon/chats/chat-1/workspace-links"); + }); + + it("reads planned agent turn traces", async () => { + const trace = [ + { + turn: { + id: "turn-1", + session_id: "session-1", + chat_id: "chat-1", + chat_message_id: "message-1", + inbox_seq: 4, + status: "completed", + received_at: 5, + activity_started_at: 6, + ended_at: 7, + }, + metrics: { + turn_id: "turn-1", + input_tokens: 10, + output_tokens: 20, + cache_read_tokens: 0, + cache_write_tokens: 0, + tool_call_count: 1, + outbox_write_count: 2, + error_count: 0, + updated_at: 8, + }, + events: [], + }, + ]; + const fetchMock = mockFetch([{ status: 200, body: trace }]); + vi.stubGlobal("fetch", fetchMock); + + const c = new DaemonClient("/api/daemon"); + await expect(c.getAgentTrace("session-1")).resolves.toEqual(trace); + + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe("/api/daemon/observability/agents/session-1"); + expect(init.headers).not.toMatchObject({ Authorization: expect.any(String) }); + }); }); diff --git a/src/lib/daemon/client.ts b/src/lib/daemon/client.ts index cbb4bfd..120c2a7 100644 --- a/src/lib/daemon/client.ts +++ b/src/lib/daemon/client.ts @@ -1,4 +1,20 @@ -import type { DaemonSession, EventRecord, Agent, Channel } from "./types"; +import type { + Agent, + AgentProfile, + AgentTurn, + AgentTurnTrace, + Channel, + Chat, + ChatAgentLink, + ChatMetrics, + ChatMessage, + ChatTranscriptEntry, + ChatWorkspaceLink, + DaemonSession, + EventRecord, + SavedAgentProfile, + SendChatMessageRequest, +} from "./types"; export class DaemonHttpError extends Error { name = "DaemonHttpError"; @@ -22,14 +38,45 @@ export interface SendMessageRequest { reason?: string; } +export interface SaveProfileRequest { + id?: string; + handle?: string; + name?: string; + description?: string | null; + runtime?: Agent; + model?: string | null; + skills?: string[]; + prompt?: string; + default_workspace_behavior?: string | null; + outbox_instructions?: string | null; +} + +export interface TestProfileInChatRequest { + chat_id?: string; + body?: string; +} + +export interface CreateChatRequest { + project_id: string; + title: string; +} + +export interface AddWorkspaceLinkRequest { + project_id: string; + workspace_id: string; + path: string; + label: string; + branch?: string | null; + focused: boolean; +} + export class DaemonClient { - constructor(private baseUrl: string, private token: string) {} + constructor(private baseUrl = "/api/daemon") {} private async call(path: string, init?: RequestInit): Promise { const res = await fetch(`${this.baseUrl}${path}`, { ...init, headers: { - "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json", ...(init?.headers ?? {}), }, @@ -73,9 +120,64 @@ export class DaemonClient { const q = new URLSearchParams(); q.set("since", String(since)); if (channels && channels.length) q.set("channels", channels.join(",")); - q.set("token", this.token); return `${base}/sessions/${id}/stream?${q.toString()}`; } - get bearer(): string { return this.token; } + listProfiles(): Promise { + return this.call("/profiles"); + } + saveProfile(req: SaveProfileRequest): Promise { + if (req.id) { + return this.call(`/profiles/${req.id}`, { method: "PATCH", body: JSON.stringify(req) }); + } + return this.call("/profiles", { method: "POST", body: JSON.stringify(req) }); + } + archiveProfile(id: string): Promise { + return this.call(`/profiles/${id}/archive`, { method: "POST" }); + } + restoreProfile(id: string): Promise { + return this.call(`/profiles/${id}/restore`, { method: "POST" }); + } + testProfileInChat(id: string, req: TestProfileInChatRequest = {}): Promise { + return this.call(`/profiles/${id}/test-chat`, { method: "POST", body: JSON.stringify(req) }); + } + + listChats(): Promise { + return this.call("/chats"); + } + createChat(req: CreateChatRequest): Promise { + return this.call("/chats", { method: "POST", body: JSON.stringify(req) }); + } + deleteChat(id: string): Promise { + return this.call(`/chats/${id}`, { method: "DELETE" }); + } + sendChatMessage(id: string, req: SendChatMessageRequest): Promise<{ message: ChatMessage; turns: AgentTurn[] }> { + return this.call(`/chats/${id}/messages`, { method: "POST", body: JSON.stringify(req) }); + } + readChatTranscript(id: string): Promise { + return this.call(`/chats/${id}/transcript`); + } + listChatAgentLinks(id: string): Promise { + return this.call(`/chats/${id}/agent-links`); + } + listChatWorkspaceLinks(id: string): Promise { + return this.call(`/chats/${id}/workspace-links`); + } + chatStreamUrl(id: string): string { + const base = this.baseUrl.replace(/^http/, "ws"); + return `${base}/chats/${id}/stream`; + } + addWorkspaceLink(id: string, req: AddWorkspaceLinkRequest): Promise { + return this.call(`/chats/${id}/workspace-links`, { method: "POST", body: JSON.stringify(req) }); + } + focusWorkspaceLink(id: string, linkId: string): Promise { + return this.call(`/chats/${id}/workspace-links/${linkId}/focus`, { method: "PATCH" }); + } + + getAgentTrace(sessionId: string): Promise { + return this.call(`/observability/agents/${sessionId}`); + } + getChatMetrics(chatId: string): Promise { + return this.call(`/observability/chats/${chatId}/metrics`); + } } diff --git a/src/lib/daemon/store.svelte.ts b/src/lib/daemon/store.svelte.ts index c7006fc..2afb122 100644 --- a/src/lib/daemon/store.svelte.ts +++ b/src/lib/daemon/store.svelte.ts @@ -1,42 +1,55 @@ -import { command } from "$lib/backend"; import { SvelteMap } from "svelte/reactivity"; import { DaemonClient } from "./client"; -import type { DaemonSession } from "./types"; +import type { + AgentProfile, + AgentTurnTrace, + Chat, + ChatAgentLink, + ChatMetrics, + ChatTranscriptEntry, + ChatWorkspaceLink, + DaemonSession, +} from "./types"; import { emptyTranscript, type TranscriptState } from "./reducer"; -const BASE_URL = "http://127.0.0.1:4867"; +const BASE_URL = "/api/daemon"; interface StoreState { - token: string | null; reachable: boolean; client: DaemonClient | null; sessions: SvelteMap; transcripts: SvelteMap; activeSessionId: string | null; + profiles: SvelteMap; + chats: SvelteMap; + activeChatId: string | null; + chatTranscripts: SvelteMap; + chatAgentLinks: SvelteMap; + chatWorkspaceLinks: SvelteMap; + chatSummaries: SvelteMap; + agentTraces: SvelteMap; newChatTarget: { projectId: string; projectCwd: string } | null; } export const daemonStore = $state({ - token: null, reachable: false, client: null, sessions: new SvelteMap(), transcripts: new SvelteMap(), activeSessionId: null, + profiles: new SvelteMap(), + chats: new SvelteMap(), + activeChatId: null, + chatTranscripts: new SvelteMap(), + chatAgentLinks: new SvelteMap(), + chatWorkspaceLinks: new SvelteMap(), + chatSummaries: new SvelteMap(), + agentTraces: new SvelteMap(), newChatTarget: null, }); export async function bootstrap(): Promise { - try { - const token = await command("read_daemon_token"); - daemonStore.token = token; - daemonStore.client = new DaemonClient(BASE_URL, token); - } catch (e) { - daemonStore.reachable = false; - daemonStore.token = null; - daemonStore.client = null; - return; - } + daemonStore.client = new DaemonClient(BASE_URL); await pingDaemon(); } @@ -66,3 +79,64 @@ export async function loadSessions(): Promise { } } } + +export async function loadProfiles(): Promise { + if (!daemonStore.client) return; + const list = await daemonStore.client.listProfiles(); + daemonStore.profiles.clear(); + for (const profile of list) { + daemonStore.profiles.set(profile.id, profile); + } +} + +export async function loadChats(): Promise { + if (!daemonStore.client) return; + const list = await daemonStore.client.listChats(); + daemonStore.chats.clear(); + for (const chat of list) { + daemonStore.chats.set(chat.id, chat); + if (!daemonStore.chatTranscripts.has(chat.id)) { + daemonStore.chatTranscripts.set(chat.id, []); + } + } +} + +export async function loadChatTranscript(chatId: string): Promise { + if (!daemonStore.client) return; + const transcript = await daemonStore.client.readChatTranscript(chatId); + daemonStore.chatTranscripts.set(chatId, transcript); +} + +export async function loadChatLinks(chatId: string): Promise { + if (!daemonStore.client) return; + const [agentLinks, workspaceLinks] = await Promise.allSettled([ + daemonStore.client.listChatAgentLinks(chatId), + daemonStore.client.listChatWorkspaceLinks(chatId), + ]); + if (agentLinks.status === "fulfilled") { + daemonStore.chatAgentLinks.set(chatId, agentLinks.value); + } else { + daemonStore.chatAgentLinks.set(chatId, []); + } + if (workspaceLinks.status === "fulfilled") { + daemonStore.chatWorkspaceLinks.set(chatId, workspaceLinks.value); + } else { + daemonStore.chatWorkspaceLinks.set(chatId, []); + } + const errors = [agentLinks, workspaceLinks].flatMap((result) => ( + result.status === "rejected" ? [result.reason] : [] + )); + if (errors.length > 0) throw errors[0]; +} + +export async function loadAgentTrace(sessionId: string): Promise { + if (!daemonStore.client) return; + const trace = await daemonStore.client.getAgentTrace(sessionId); + daemonStore.agentTraces.set(sessionId, trace); +} + +export async function loadChatMetrics(chatId: string): Promise { + if (!daemonStore.client) return; + const metrics = await daemonStore.client.getChatMetrics(chatId); + daemonStore.chatSummaries.set(chatId, metrics); +} diff --git a/src/lib/daemon/store.test.ts b/src/lib/daemon/store.test.ts index 8aa79fd..23d955e 100644 --- a/src/lib/daemon/store.test.ts +++ b/src/lib/daemon/store.test.ts @@ -3,12 +3,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; describe("daemon store bootstrap", () => { beforeEach(() => vi.resetModules()); - it("sets reachable=true on successful ping", async () => { + it("sets reachable=true on successful same-origin ping without reading a token", async () => { + const commandMock = vi.fn(async (cmd: string) => { + throw new Error("unexpected command " + cmd); + }); vi.doMock("$lib/backend", () => ({ - command: vi.fn(async (cmd: string) => { - if (cmd === "read_daemon_token") return "TOK"; - throw new Error("unexpected command " + cmd); - }), + command: commandMock, listen: () => () => {}, })); const fetchMock = vi.fn(async () => ({ ok: true, status: 200, json: async () => [] } as any)); @@ -16,29 +16,193 @@ describe("daemon store bootstrap", () => { const { daemonStore, bootstrap } = await import("./store.svelte"); await bootstrap(); expect(daemonStore.reachable).toBe(true); - expect(daemonStore.token).toBe("TOK"); + expect(commandMock).not.toHaveBeenCalled(); + expect("token" in daemonStore).toBe(false); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe("/api/daemon/sessions"); + expect(init.headers).not.toMatchObject({ Authorization: expect.any(String) }); }); it("sets reachable=false when ping fails", async () => { + const commandMock = vi.fn(async (cmd: string) => { + throw new Error("unexpected command " + cmd); + }); vi.doMock("$lib/backend", () => ({ - command: vi.fn(async () => "TOK"), + command: commandMock, listen: () => () => {}, })); vi.stubGlobal("fetch", vi.fn(async () => { throw new TypeError("connect refused"); })); const { daemonStore, bootstrap } = await import("./store.svelte"); await bootstrap(); expect(daemonStore.reachable).toBe(false); + expect(commandMock).not.toHaveBeenCalled(); }); - it("sets reachable=false when read_daemon_token fails", async () => { - vi.doMock("$lib/backend", () => ({ - command: vi.fn(async () => { throw new Error("token missing"); }), - listen: () => () => {}, - })); + it("has separate reactive maps for profiles chats transcripts summaries and traces", async () => { + vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, status: 200, json: async () => [] } as any))); + const { daemonStore } = await import("./store.svelte"); + + expect(daemonStore.profiles.size).toBe(0); + expect(daemonStore.chats.size).toBe(0); + expect(daemonStore.activeChatId).toBeNull(); + expect(daemonStore.chatTranscripts.size).toBe(0); + expect(daemonStore.chatSummaries.size).toBe(0); + expect(daemonStore.agentTraces.size).toBe(0); + expect("token" in daemonStore).toBe(false); + }); + + it("loads profiles chats transcripts and traces through the daemon client", async () => { + const profile = { + id: "profile-1", + handle: "planner", + name: "Planner", + description: "Plans work", + runtime: "claude", + skills: ["planning"], + prompt: "Plan carefully", + archived_at: null, + avatar_asset_path: null, + avatar_status: "none", + avatar_error: null, + active_version_id: "version-1", + created_at: 1, + updated_at: 2, + }; + const chat = { + id: "chat-1", + project_id: "project-1", + title: "Plan", + created_at: 3, + updated_at: 4, + deleted_at: null, + }; + const transcript = [ + { + type: "user_message", + message: { + id: "message-1", + chat_id: "chat-1", + idempotency_id: null, + body: "hello", + token_spans: [], + created_at: 5, + }, + }, + ]; + const trace = [ + { + turn: { + id: "turn-1", + session_id: "session-1", + chat_id: "chat-1", + chat_message_id: "message-1", + inbox_seq: 6, + status: "completed", + received_at: 7, + activity_started_at: 8, + ended_at: 9, + }, + metrics: { + turn_id: "turn-1", + input_tokens: 10, + output_tokens: 20, + cache_read_tokens: 0, + cache_write_tokens: 0, + tool_call_count: 1, + outbox_write_count: 2, + error_count: 0, + updated_at: 11, + }, + events: [], + }, + ]; + const metrics = { + chat_id: "chat-1", + turn_count: 1, + input_tokens: 10, + output_tokens: 20, + cache_read_tokens: 0, + cache_write_tokens: 0, + tool_call_count: 1, + outbox_write_count: 2, + error_count: 0, + updated_at: 12, + }; vi.stubGlobal("fetch", vi.fn()); - const { daemonStore, bootstrap } = await import("./store.svelte"); - await bootstrap(); - expect(daemonStore.reachable).toBe(false); - expect(daemonStore.token).toBeNull(); + const { + daemonStore, + loadProfiles, + loadChats, + loadChatTranscript, + loadChatLinks, + loadAgentTrace, + loadChatMetrics, + } = await import("./store.svelte"); + daemonStore.client = { + listProfiles: vi.fn(async () => [profile]), + listChats: vi.fn(async () => [chat]), + readChatTranscript: vi.fn(async () => transcript), + listChatAgentLinks: vi.fn(async () => []), + listChatWorkspaceLinks: vi.fn(async () => []), + getAgentTrace: vi.fn(async () => trace), + getChatMetrics: vi.fn(async () => metrics), + } as any; + + await loadProfiles(); + await loadChats(); + await loadChatTranscript("chat-1"); + await loadChatLinks("chat-1"); + await loadAgentTrace("session-1"); + await loadChatMetrics("chat-1"); + + expect(daemonStore.profiles.get("profile-1")).toEqual(profile); + expect(daemonStore.chats.get("chat-1")).toEqual(chat); + expect(daemonStore.chatTranscripts.get("chat-1")).toEqual(transcript); + expect(daemonStore.chatAgentLinks.get("chat-1")).toEqual([]); + expect(daemonStore.chatWorkspaceLinks.get("chat-1")).toEqual([]); + expect(daemonStore.agentTraces.get("session-1")).toEqual(trace); + expect(daemonStore.chatSummaries.get("chat-1")).toEqual(metrics); + expect("token" in daemonStore).toBe(false); + }); + + it("keeps successful chat link hydration when the other link request fails", async () => { + vi.stubGlobal("fetch", vi.fn()); + const { daemonStore, loadChatLinks } = await import("./store.svelte"); + const agentLinks = [ + { + id: "agent-link-1", + chat_id: "chat-1", + session_id: "session-1", + profile_id: "profile-1", + profile_version_id: "version-1", + route_type: "reusable", + focused: true, + token_source: "@reviewer", + created_at: 1, + }, + ]; + daemonStore.chatWorkspaceLinks.set("chat-1", [ + { + id: "stale", + chat_id: "chat-1", + project_id: "project-1", + workspace_id: "workspace-1", + path: "/stale", + label: "stale", + branch: null, + focused: true, + created_at: 1, + updated_at: 1, + }, + ]); + daemonStore.client = { + listChatAgentLinks: vi.fn(async () => agentLinks), + listChatWorkspaceLinks: vi.fn(async () => { throw new Error("workspace links unavailable"); }), + } as any; + + await expect(loadChatLinks("chat-1")).rejects.toThrow("workspace links unavailable"); + + expect(daemonStore.chatAgentLinks.get("chat-1")).toEqual(agentLinks); + expect(daemonStore.chatWorkspaceLinks.get("chat-1")).toEqual([]); }); }); diff --git a/src/lib/daemon/stream.test.ts b/src/lib/daemon/stream.test.ts index f9a4d32..b4208f9 100644 --- a/src/lib/daemon/stream.test.ts +++ b/src/lib/daemon/stream.test.ts @@ -19,30 +19,34 @@ describe("stream", () => { MockWebSocket.clear(); vi.resetModules(); vi.stubGlobal("WebSocket", MockWebSocket as any); - vi.doMock("$lib/backend", () => ({ command: vi.fn(async () => "TOK"), listen: () => () => {} })); + vi.doMock("$lib/backend", () => ({ + command: vi.fn(async (cmd: string) => { + throw new Error("unexpected command " + cmd); + }), + listen: () => () => {}, + })); }); it("reconnects with since=", async () => { const { daemonStore } = await import("./store.svelte"); - daemonStore.token = "TOK"; - daemonStore.client = { wsUrl: (id: string, since: number) => `ws://x/sessions/${id}/stream?since=${since}&token=TOK` } as any; + const { DaemonClient } = await import("./client"); + daemonStore.client = new DaemonClient("/api/daemon"); daemonStore.transcripts.set("s1", { events: [], lastSeq: 5, inProgressBlocks: new Map(), statusState: null, tokenUsage: null, seenSeq: new Set() }); const { openStream } = await import("./stream"); const handle = openStream("s1"); expect(MockWebSocket.instances.length).toBe(1); - // first URL should have since=5 - expect(MockWebSocket.instances[0].url).toContain("since=5"); + expect(MockWebSocket.instances[0].url).toBe("/api/daemon/sessions/s1/stream?since=5"); MockWebSocket.instances[0].close(); await vi.advanceTimersByTimeAsync(1100); expect(MockWebSocket.instances.length).toBe(2); - expect(MockWebSocket.instances[1].url).toContain("since=5"); + expect(MockWebSocket.instances[1].url).toBe("/api/daemon/sessions/s1/stream?since=5"); handle.close(); }); it("close() prevents further reconnects", async () => { const { daemonStore } = await import("./store.svelte"); - daemonStore.token = "TOK"; - daemonStore.client = { wsUrl: (id: string, since: number) => `ws://x/sessions/${id}/stream?since=${since}&token=TOK` } as any; + const { DaemonClient } = await import("./client"); + daemonStore.client = new DaemonClient("/api/daemon"); daemonStore.transcripts.set("s2", { events: [], lastSeq: 0, inProgressBlocks: new Map(), statusState: null, tokenUsage: null, seenSeq: new Set() }); const { openStream } = await import("./stream"); const handle = openStream("s2"); @@ -53,8 +57,8 @@ describe("stream", () => { it("incoming event is reduced into the transcript", async () => { const { daemonStore } = await import("./store.svelte"); - daemonStore.token = "TOK"; - daemonStore.client = { wsUrl: (id: string, since: number) => `ws://x/sessions/${id}/stream?since=${since}&token=TOK` } as any; + const { DaemonClient } = await import("./client"); + daemonStore.client = new DaemonClient("/api/daemon"); daemonStore.transcripts.set("s3", { events: [], lastSeq: 0, inProgressBlocks: new Map(), statusState: null, tokenUsage: null, seenSeq: new Set() }); const { openStream } = await import("./stream"); const handle = openStream("s3"); diff --git a/src/lib/daemon/types.test.ts b/src/lib/daemon/types.test.ts index ba95103..92a1ee4 100644 --- a/src/lib/daemon/types.test.ts +++ b/src/lib/daemon/types.test.ts @@ -1,6 +1,17 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, expectTypeOf } from "vitest"; import fixture from "./__fixtures__/events.json"; -import type { EventRecord, OutboxEvent } from "./types"; +import type { + AgentProfile, + AgentProfileVersion, + AgentTurnTrace, + Chat, + ChatMetrics, + ChatMessage, + EventRecord, + OutboxEvent, + RouteToken, + TurnMetrics, +} from "./types"; describe("daemon event types", () => { it("parses fixture events without type errors", () => { @@ -19,4 +30,155 @@ describe("daemon event types", () => { }; expect(e.payload.text).toBe("hello"); }); + + it("models tagged chat route tokens and chat message spans", () => { + const token: RouteToken = { kind: "shadow", handle: "critic", start: 0, end: 7 }; + const message: ChatMessage = { + id: "message-1", + chat_id: "chat-1", + idempotency_id: null, + body: "@critic check this", + token_spans: [token], + created_at: 10, + }; + + expect(message.token_spans).toEqual([{ kind: "shadow", handle: "critic", start: 0, end: 7 }]); + }); + + it("allows inbound chat messages to preserve non-route persisted token spans", () => { + const persistedSpans = { + source: "legacy-parser", + spans: [{ byte_range: [0, 5], label: "mention", metadata: { agent_id: "profile-1" } }], + }; + const message: ChatMessage = { + id: "message-1", + chat_id: "chat-1", + idempotency_id: null, + body: "hello", + token_spans: persistedSpans, + created_at: 10, + }; + + expect(message.token_spans).toEqual(persistedSpans); + }); + + it("models profile chat metrics and trace payloads additively", () => { + const profile: AgentProfile = { + id: "profile-1", + handle: "planner", + name: "Planner", + description: "Plans work", + runtime: "claude", + skills: ["planning"], + prompt: "Plan carefully", + archived_at: null, + avatar_asset_path: null, + avatar_status: "none", + avatar_error: null, + active_version_id: "version-1", + created_at: 1, + updated_at: 2, + }; + const chat: Chat = { + id: "chat-1", + project_id: "project-1", + title: "Plan", + created_at: 3, + updated_at: 4, + deleted_at: null, + }; + const metrics: ChatMetrics = { + chat_id: chat.id, + turn_count: 1, + input_tokens: 10, + output_tokens: 20, + cache_read_tokens: 0, + cache_write_tokens: 0, + tool_call_count: 2, + outbox_write_count: 3, + error_count: 0, + updated_at: 5, + }; + const trace: AgentTurnTrace = { + turn: { + id: "turn-1", + session_id: "session-1", + chat_id: chat.id, + chat_message_id: "message-1", + inbox_seq: 7, + status: "completed", + received_at: 8, + activity_started_at: 9, + ended_at: 10, + }, + metrics: { + turn_id: "turn-1", + input_tokens: 10, + output_tokens: 20, + cache_read_tokens: 0, + cache_write_tokens: 0, + tool_call_count: 2, + outbox_write_count: 3, + error_count: 0, + updated_at: 11, + }, + events: [ + { + session_id: "session-1", + seq: 1, + channel: "outbox", + kind: "agent_text", + payload: {}, + created_at: 12, + applied_at: null, + chat_id: chat.id, + chat_seq: 1, + turn_id: "turn-1", + }, + ], + }; + + expect(profile.handle).toBe("planner"); + expect(metrics.chat_id).toBe("chat-1"); + expect(trace.events[0].turn_id).toBe("turn-1"); + }); + + it("models daemon profile strings as non-null and saved versions as non-null instructions", () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + it("models nullable turn token metrics and traces without metrics", () => { + const metrics: TurnMetrics = { + turn_id: "turn-1", + input_tokens: null, + output_tokens: null, + cache_read_tokens: null, + cache_write_tokens: null, + tool_call_count: 0, + outbox_write_count: 0, + error_count: 0, + updated_at: 11, + }; + const trace: AgentTurnTrace = { + turn: { + id: "turn-1", + session_id: "session-1", + chat_id: "chat-1", + chat_message_id: "message-1", + inbox_seq: 7, + status: "completed", + received_at: 8, + activity_started_at: 9, + ended_at: 10, + }, + metrics: null, + events: [], + }; + + expect(metrics.input_tokens).toBeNull(); + expect(trace.metrics).toBeNull(); + }); }); diff --git a/src/lib/daemon/types.ts b/src/lib/daemon/types.ts index 104eede..fc0f230 100644 --- a/src/lib/daemon/types.ts +++ b/src/lib/daemon/types.ts @@ -1,11 +1,17 @@ export type Agent = "claude" | "codex"; export type SessionStatus = "starting" | "running" | "interrupted" | "ended" | "failed"; export type Channel = "inbox" | "outbox" | "system"; +export type RouteTokenKind = "reusable" | "shadow"; export interface DaemonSession { id: string; label: string; agent: Agent; + agent_profile_id?: string | null; + session_kind?: "raw" | "reusable" | "shadow"; + owner_chat_id?: string | null; + profile_version_id?: string | null; + launch_context_snapshot?: string | null; cwd: string; args: string[]; status: SessionStatus; @@ -25,8 +31,201 @@ export interface EventRecord { payload: unknown; created_at: number; applied_at: number | null; + chat_id?: string | null; + chat_seq?: number | null; + turn_id?: string | null; } +export interface AgentProfile { + id: string; + handle: string; + name: string; + description: string; + runtime: Agent; + skills: string[]; + prompt: string; + archived_at: number | null; + avatar_asset_path: string | null; + avatar_status: string; + avatar_error: string | null; + active_version_id: string | null; + created_at: number; + updated_at: number; +} + +export interface AgentProfileVersion { + id: string; + profile_id: string; + runtime: Agent; + model: string | null; + prompt: string; + skills: string[]; + default_workspace_behavior: string; + outbox_instructions: string; + validation_result: unknown; + created_at: number; +} + +export interface SavedAgentProfile { + profile: AgentProfile; + version: AgentProfileVersion; +} + +export interface Chat { + id: string; + project_id: string; + title: string; + created_at: number; + updated_at: number; + deleted_at: number | null; +} + +export interface RouteToken { + kind: RouteTokenKind; + handle: string; + start: number; + end: number; +} + +export interface ChatMessage { + id: string; + chat_id: string; + idempotency_id: string | null; + body: string; + token_spans: unknown; + created_at: number; +} + +export interface ChatAgentLink { + id: string; + chat_id: string; + session_id: string; + profile_id: string; + profile_version_id: string; + route_type: string; + focused: boolean; + token_source: string | null; + created_at: number; +} + +export interface ChatWorkspaceLink { + id: string; + chat_id: string; + project_id: string; + workspace_id: string; + path: string; + label: string; + branch: string | null; + focused: boolean; + created_at: number; + updated_at: number; +} + +export interface SendChatMessageRequest { + idempotency_id?: string; + body: string; + tokens?: RouteToken[]; +} + +export interface AgentTurn { + id: string; + session_id: string; + chat_id: string; + chat_message_id: string; + inbox_seq: number; + status: string; + received_at: number; + activity_started_at: number | null; + ended_at: number | null; +} + +export interface TurnMetrics { + turn_id: string; + input_tokens: number | null; + output_tokens: number | null; + cache_read_tokens: number | null; + cache_write_tokens: number | null; + tool_call_count: number; + outbox_write_count: number; + error_count: number; + updated_at: number; +} + +export interface AgentTurnTrace { + turn: AgentTurn; + metrics: TurnMetrics | null; + events: EventRecord[]; +} + +export interface ChatMetrics { + chat_id: string; + turn_count: number; + input_tokens: number; + output_tokens: number; + cache_read_tokens: number; + cache_write_tokens: number; + tool_call_count: number; + outbox_write_count: number; + error_count: number; + total_elapsed_ms?: number | null; + average_turn_ms?: number | null; + slowest_turn_ms?: number | null; + updated_at: number; + agents?: ChatAgentMetrics[]; + turns?: ChatTurnMetrics[]; + workspace_links?: ChatWorkspaceLink[]; +} + +export interface ChatAgentMetrics { + session_id: string; + profile_id: string; + profile_version_id: string; + route_type: string; + focused: boolean; + token_source: string; + status: SessionStatus | null; + turn_count: number; + input_tokens: number | null; + output_tokens: number | null; + cache_read_tokens: number | null; + cache_write_tokens: number | null; + total_tokens: number | null; + tool_call_count: number | null; + outbox_write_count: number | null; + error_count: number | null; + total_elapsed_ms: number | null; + average_turn_ms: number | null; + slowest_turn_ms: number | null; + current_turn_id: string | null; + updated_at: number; +} + +export interface ChatTurnMetrics { + turn_id: string; + session_id: string; + chat_id: string; + chat_message_id: string; + status: string; + received_at: number; + activity_started_at: number | null; + ended_at: number | null; + activity_latency_ms: number | null; + duration_ms: number | null; + input_tokens: number | null; + output_tokens: number | null; + cache_read_tokens: number | null; + cache_write_tokens: number | null; + total_tokens: number | null; + tool_call_count: number | null; + outbox_write_count: number | null; + error_count: number | null; + updated_at: number; +} + +export type ChatTranscriptEntry = + | { type: "user_message"; message: ChatMessage } + | { type: "outbox_event"; event: EventRecord }; + export type OutboxEvent = | { kind: "agent_text"; payload: { message_id: string; block_id: string; text: string; role?: string } } | { kind: "agent_text_delta"; payload: { message_id: string; block_id: string; delta: string; role?: string } } diff --git a/src/lib/focus-helpers.ts b/src/lib/focus-helpers.ts index 839ae7b..bbaa39b 100644 --- a/src/lib/focus-helpers.ts +++ b/src/lib/focus-helpers.ts @@ -11,7 +11,7 @@ export function focusForModeSwitch( ): FocusTarget { if (!current) return null; - if (current.type === "session" && newMode !== "chat") { + if ((current.type === "session" || current.type === "chat") && newMode !== "chat") { return { type: "project", projectId: current.projectId }; } diff --git a/src/lib/observability/AgentObservabilityWorkspace.svelte b/src/lib/observability/AgentObservabilityWorkspace.svelte new file mode 100644 index 0000000..07e317e --- /dev/null +++ b/src/lib/observability/AgentObservabilityWorkspace.svelte @@ -0,0 +1,77 @@ + + + diff --git a/src/lib/observability/AgentTraceView.svelte b/src/lib/observability/AgentTraceView.svelte new file mode 100644 index 0000000..98b0a04 --- /dev/null +++ b/src/lib/observability/AgentTraceView.svelte @@ -0,0 +1,438 @@ + + +
+ {#if session} +
+
+

Agent Trace

+

{session.label}

+
+
+ {session.agent} + {session.status} + {session.session_kind ?? "unowned"} +
+
+ + {#if error} + + {/if} + +
+
+ Current turn + {latestTrace?.turn.id ?? "none"} +
+
+ Turns + {formatMetricValue(traces.length, "turns")} +
+
+ Runtime + {session.native_session_id ?? "unavailable"} +
+
+ Ownership + Owner {ownerChatLabel()} +
+
+ Profile + {session.agent_profile_id ?? "unavailable"} +
+
+ Version + {session.profile_version_id ?? "unavailable"} +
+ {#if loading} +
+ State + loading +
+ {/if} +
+ + + +
+ {#each sortedTraces as trace, index (trace.turn.id)} +
+ + {trace.turn.id} + {trace.turn.status} + {chatTitle(trace.turn.chat_id)} + {trace.turn.ended_at == null ? "active" : formatDuration(trace.turn.received_at, trace.turn.ended_at)} + {formatMetricValue(tokenTotal(trace.metrics), "tokens")} + {formatMetricValue(trace.metrics?.tool_call_count, "tools")} + {formatMetricValue(trace.metrics?.outbox_write_count, "outbox")} + {formatMetricValue(trace.metrics?.error_count, "errors")} + +
+
+ Input {formatMetricValue(trace.metrics?.input_tokens, "tokens")} + Output {formatMetricValue(trace.metrics?.output_tokens, "tokens")} + Outbox {formatMetricValue(trace.metrics?.outbox_write_count, "outbox")} + Errors {formatMetricValue(trace.metrics?.error_count, "errors")} +
+

Timing

+
+ Received {formatTimestamp(trace.turn.received_at)} + Activity {formatTimestamp(trace.turn.activity_started_at)} + Ended {formatTimestamp(trace.turn.ended_at)} + Latency {formatDuration(trace.turn.received_at, trace.turn.activity_started_at)} + Duration {formatDuration(trace.turn.received_at, trace.turn.ended_at)} + Updated {formatTimestamp(trace.metrics?.updated_at ?? trace.turn.ended_at ?? trace.turn.activity_started_at)} +
+
+ {#each trace.events as event (`${event.session_id}:${event.seq}`)} +
+
+ {event.channel} + {event.kind.replaceAll("_", " ")} +
+

{eventSummary(event)}

+
{eventPayload(event)}
+
+ {:else} +

No runtime events recorded for this turn.

+ {/each} +
+
+
+ {:else} +

No turns recorded for this agent.

+ {/each} +
+ {:else} +
Select an agent to inspect.
+ {/if} +
+ + diff --git a/src/lib/observability/AgentTraceView.test.svelte.ts b/src/lib/observability/AgentTraceView.test.svelte.ts new file mode 100644 index 0000000..b396d07 --- /dev/null +++ b/src/lib/observability/AgentTraceView.test.svelte.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "vitest"; +import { render } from "@testing-library/svelte"; +import AgentTraceView from "./AgentTraceView.svelte"; +import type { AgentTurnTrace, Chat, ChatWorkspaceLink, DaemonSession } from "../daemon/types"; + +const session: DaemonSession = { + id: "session-1", + label: "Reviewer", + agent: "codex", + agent_profile_id: "profile-1", + session_kind: "reusable", + owner_chat_id: "chat-1", + profile_version_id: "version-1", + launch_context_snapshot: null, + cwd: "/repo/controller", + args: [], + status: "running", + native_session_id: "native-1", + pid: 123, + created_at: 1, + updated_at: 2, + ended_at: null, + end_reason: null, +}; + +const chats = new Map([ + ["chat-1", { + id: "chat-1", + project_id: "project-1", + title: "Routing review", + created_at: 1, + updated_at: 2, + deleted_at: null, + }], +]); + +const workspaceLinks: ChatWorkspaceLink[] = [ + { + id: "workspace-1", + chat_id: "chat-1", + project_id: "project-1", + workspace_id: "workspace-1", + path: "/repo/controller", + label: "controller", + branch: "codex/routing", + focused: true, + created_at: 1, + updated_at: 1, + }, +]; + +const traces: AgentTurnTrace[] = [ + { + turn: { + id: "turn-old", + session_id: "session-1", + chat_id: "chat-1", + chat_message_id: "message-1", + inbox_seq: 1, + status: "completed", + received_at: 1_000, + activity_started_at: 1_100, + ended_at: 1_900, + }, + metrics: null, + events: [ + { + session_id: "session-1", + seq: 1, + channel: "inbox", + kind: "user_text", + payload: { text: "please review" }, + created_at: 1_000, + applied_at: 1_000, + chat_id: "chat-1", + chat_seq: 1, + turn_id: "turn-old", + }, + ], + }, + { + turn: { + id: "turn-new", + session_id: "session-1", + chat_id: "chat-1", + chat_message_id: "message-2", + inbox_seq: 2, + status: "active", + received_at: 2_000, + activity_started_at: 2_050, + ended_at: null, + }, + metrics: { + turn_id: "turn-new", + input_tokens: 0, + output_tokens: 14, + cache_read_tokens: 0, + cache_write_tokens: 0, + tool_call_count: 1, + outbox_write_count: 1, + error_count: 0, + updated_at: 2_100, + }, + events: [ + { + session_id: "session-1", + seq: 2, + channel: "outbox", + kind: "agent_thinking", + payload: { text: "checking the changed files" }, + created_at: 2_050, + applied_at: 2_050, + chat_id: "chat-1", + chat_seq: 2, + turn_id: "turn-new", + }, + { + session_id: "session-1", + seq: 3, + channel: "outbox", + kind: "tool_call", + payload: { tool: "shell", input: { cmd: "git diff" } }, + created_at: 2_060, + applied_at: 2_060, + chat_id: "chat-1", + chat_seq: 3, + turn_id: "turn-new", + }, + { + session_id: "session-1", + seq: 4, + channel: "outbox", + kind: "agent_text", + payload: { text: "No findings." }, + created_at: 2_100, + applied_at: 2_100, + chat_id: "chat-1", + chat_seq: 4, + turn_id: "turn-new", + }, + ], + }, +]; + +describe("AgentTraceView", () => { + it("renders the newest turn first with metrics and expandable runtime details", () => { + const { getAllByText, getByText, getAllByTestId } = render(AgentTraceView, { + session, + traces, + chats, + workspaceLinks, + }); + + expect(getByText("Reviewer")).toBeTruthy(); + expect(getByText("codex")).toBeTruthy(); + expect(getByText("running")).toBeTruthy(); + expect(getByText("reusable")).toBeTruthy(); + expect(getByText("profile-1")).toBeTruthy(); + expect(getByText("version-1")).toBeTruthy(); + expect(getByText("Owner Routing review")).toBeTruthy(); + expect(getAllByText("Routing review").length).toBeGreaterThan(0); + expect(getByText("controller")).toBeTruthy(); + + const rows = getAllByTestId("turn-row"); + expect(rows[0].textContent).toContain("turn-new"); + expect(rows[1].textContent).toContain("turn-old"); + expect(rows[0].textContent).toContain("14 tokens"); + expect(rows[0].textContent).toContain("1 tool"); + expect(rows[0].textContent).toContain("1 outbox write"); + expect(rows[0].textContent).toContain("0 errors"); + expect(rows[1].textContent).toContain("unavailable"); + + expect(getAllByText("Timing").length).toBeGreaterThan(0); + expect(getAllByText(/Received/).length).toBeGreaterThan(0); + expect(getAllByText(/Activity/).length).toBeGreaterThan(0); + expect(getAllByText(/Ended/).length).toBeGreaterThan(0); + expect(getAllByText("unavailable").length).toBeGreaterThan(0); + expect(getByText("checking the changed files")).toBeTruthy(); + expect(getAllByText(/shell/).length).toBeGreaterThan(0); + expect(getByText("No findings.")).toBeTruthy(); + }); +}); diff --git a/src/lib/observability/turn-format.test.ts b/src/lib/observability/turn-format.test.ts new file mode 100644 index 0000000..5cdfe4c --- /dev/null +++ b/src/lib/observability/turn-format.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { formatDuration, formatDurationMs, formatMetricValue, tokenTotal } from "./turn-format"; + +describe("formatMetricValue", () => { + it("distinguishes missing metrics from zero", () => { + expect(formatMetricValue(null, "tokens")).toBe("unavailable"); + expect(formatMetricValue(0, "tokens")).toBe("0 tokens"); + }); + + it("formats counts without pretending missing values are zero", () => { + expect(formatMetricValue(undefined, "tools")).toBe("unavailable"); + expect(formatMetricValue(1, "tools")).toBe("1 tool"); + expect(formatMetricValue(3, "errors")).toBe("3 errors"); + }); +}); + +describe("formatDuration", () => { + it("distinguishes unobserved timing from zero duration", () => { + expect(formatDuration(null, 10)).toBe("unavailable"); + expect(formatDuration(10, 10)).toBe("0ms"); + expect(formatDuration(10, 1_510)).toBe("1.5s"); + }); + + it("formats duration fields that already arrive as elapsed milliseconds", () => { + expect(formatDurationMs(null)).toBe("unavailable"); + expect(formatDurationMs(0)).toBe("0ms"); + expect(formatDurationMs(61_000)).toBe("1m 01s"); + }); +}); + +describe("tokenTotal", () => { + it("returns null when a turn has no token metrics", () => { + expect(tokenTotal(null)).toBeNull(); + }); + + it("includes zero-valued token metrics in the total", () => { + expect(tokenTotal({ + turn_id: "turn-1", + input_tokens: 0, + output_tokens: 12, + cache_read_tokens: 0, + cache_write_tokens: null, + tool_call_count: 0, + outbox_write_count: 0, + error_count: 0, + updated_at: 1, + })).toBe(12); + }); +}); diff --git a/src/lib/observability/turn-format.ts b/src/lib/observability/turn-format.ts new file mode 100644 index 0000000..1f7efbe --- /dev/null +++ b/src/lib/observability/turn-format.ts @@ -0,0 +1,92 @@ +import type { AgentTurnTrace, EventRecord, TurnMetrics } from "../daemon/types"; + +type MetricKind = "tokens" | "tools" | "errors" | "turns" | "outbox"; + +const LABELS: Record = { + tokens: ["token", "tokens"], + tools: ["tool", "tools"], + errors: ["error", "errors"], + turns: ["turn", "turns"], + outbox: ["outbox write", "outbox writes"], +}; + +export function formatMetricValue(value: number | null | undefined, kind: MetricKind): string { + if (value == null) return "unavailable"; + const [singular, plural] = LABELS[kind]; + return `${value} ${value === 1 ? singular : plural}`; +} + +export function formatDuration(start: number | null | undefined, end: number | null | undefined): string { + if (start == null || end == null) return "unavailable"; + return formatDurationMs(Math.max(0, end - start)); +} + +export function formatDurationMs(ms: number | null | undefined): string { + if (ms == null) return "unavailable"; + if (ms < 1_000) return `${ms}ms`; + if (ms < 60_000) { + const seconds = ms / 1_000; + return `${Number.isInteger(seconds) ? seconds.toFixed(0) : seconds.toFixed(1)}s`; + } + const minutes = Math.floor(ms / 60_000); + const seconds = Math.floor((ms % 60_000) / 1_000); + return `${minutes}m ${seconds.toString().padStart(2, "0")}s`; +} + +export function formatTimestamp(value: number | null | undefined): string { + if (value == null) return "unavailable"; + return new Date(value).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +export function tokenTotal(metrics: TurnMetrics | null | undefined): number | null { + if (!metrics) return null; + const parts = [ + metrics.input_tokens, + metrics.output_tokens, + metrics.cache_read_tokens, + metrics.cache_write_tokens, + ]; + if (parts.every((part) => part == null)) return null; + return parts.reduce((sum, part) => sum + (part ?? 0), 0); +} + +export function traceTokenTotal(trace: AgentTurnTrace[]): number | null { + let sawMetrics = false; + let total = 0; + for (const item of trace) { + const value = tokenTotal(item.metrics); + if (value == null) continue; + sawMetrics = true; + total += value; + } + return sawMetrics ? total : null; +} + +export function traceMetricTotal( + trace: AgentTurnTrace[], + field: "tool_call_count" | "outbox_write_count" | "error_count", +): number | null { + let sawMetrics = false; + let total = 0; + for (const item of trace) { + if (!item.metrics) continue; + sawMetrics = true; + total += item.metrics[field]; + } + return sawMetrics ? total : null; +} + +export function eventSummary(event: EventRecord): string { + const payload = event.payload as Record | null; + if (payload && typeof payload === "object") { + for (const key of ["text", "message", "delta", "reason", "code", "tool"]) { + const value = payload[key]; + if (typeof value === "string" && value.trim()) return value; + } + } + return event.kind.replaceAll("_", " "); +} diff --git a/src/lib/stores.ts b/src/lib/stores.ts index dbb113e..821702b 100644 --- a/src/lib/stores.ts +++ b/src/lib/stores.ts @@ -133,7 +133,9 @@ export interface Config { export type WorkspaceMode = | "agents" | "kanban" - | "chat"; + | "chat" + | "agent-create" + | "agent-observe"; export const workspaceMode = writable("chat"); export const workspaceModePickerVisible = writable(false); @@ -202,6 +204,8 @@ export type AgentKind = "auto-worker" | "maintainer"; export type FocusTarget = | { type: "session"; sessionId: string; projectId: string } + | { type: "chat"; chatId: string; projectId: string } + | { type: "agent-observe"; sessionId: string; projectId: string } | { type: "project"; projectId: string } | { type: "agent"; agentKind: AgentKind; projectId: string } | { type: "agent-panel"; agentKind: AgentKind; projectId: string } diff --git a/src/lib/web-backend-audit.test.ts b/src/lib/web-backend-audit.test.ts index 9e14f92..3027844 100644 --- a/src/lib/web-backend-audit.test.ts +++ b/src/lib/web-backend-audit.test.ts @@ -72,4 +72,27 @@ describe("web backend migration audit", () => { expect(stale).toEqual([]); }); + + it("keeps daemon-reachable e2e behind an explicit shared state-dir harness", () => { + const source = read("e2e/specs/chat-mode.spec.ts"); + + expect(source).toContain("TCD_E2E_DAEMON_REACHABLE"); + expect(source).toContain("THE_CONTROLLER_STATE_DIR"); + expect(source).toContain("TCD_STATE_DIR"); + expect(source).toMatch(/controllerStateDir\s*===\s*daemonStateDir/); + expect(source).toMatch(/isAbsolute\(controllerStateDir\)/); + expect(source).toMatch(/isAbsolute\(daemonStateDir\)/); + expect(source).not.toMatch( + /test\.skip\(\s*!\s*daemonBinariesPresent[\s,]/, + ); + expect(source).not.toContain("mkdtempSync"); + }); + + it("keeps the api dev proxy websocket-capable for daemon streams", () => { + const source = read("vite.config.ts"); + const apiProxy = source.match(/"\/api":\s*\{([\s\S]*?)\n\s*\},\n\s*"\/ws"/); + + expect(apiProxy?.[1] ?? "").toContain("ws: true"); + expect(apiProxy?.[1] ?? "").not.toContain("changeOrigin"); + }); }); diff --git a/vite.config.ts b/vite.config.ts index ce52871..65ac14e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -53,7 +53,10 @@ export default defineConfig(async () => ({ ignored: ["**/server/**"], }, proxy: { - "/api": `http://localhost:${axumPort}`, + "/api": { + target: `http://localhost:${axumPort}`, + ws: true, + }, "/ws": { target: `ws://localhost:${axumPort}`, ws: true,