From 8a5d5290052c4adc251f76ef0c4ad7f91c976f54 Mon Sep 17 00:00:00 2001 From: Noel Kwan Date: Wed, 29 Apr 2026 22:21:20 +0800 Subject: [PATCH 01/35] docs: add agent architecture implementation plan --- ...04-29-agent-architecture-implementation.md | 2368 +++++++++++++++++ 1 file changed, 2368 insertions(+) create mode 100644 docs/plans/2026-04-29-agent-architecture-implementation.md 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. + From 2cba1c857398b1b501e35f932f4a0ef3a0f7678d Mon Sep 17 00:00:00 2001 From: Noel Kwan Date: Thu, 30 Apr 2026 07:54:21 +0800 Subject: [PATCH 02/35] feat: add daemon gateway path handling --- server/src/daemon_gateway.rs | 27 +++++++++++++++++++++++++++ server/src/lib.rs | 1 + server/src/main.rs | 33 ++++++++++++++++++++++++++++++--- server/tests/daemon_gateway.rs | 26 ++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 server/src/daemon_gateway.rs create mode 100644 server/tests/daemon_gateway.rs diff --git a/server/src/daemon_gateway.rs b/server/src/daemon_gateway.rs new file mode 100644 index 0000000..cc01387 --- /dev/null +++ b/server/src/daemon_gateway.rs @@ -0,0 +1,27 @@ +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.is_empty() && !rest.starts_with('/') { + return Err("path must stay under /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() + }) +} 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..5afc7b6 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,7 +1,7 @@ use axum::{ extract::{ ws::{Message, WebSocket}, - State as AxumState, WebSocketUpgrade, + OriginalUri, State as AxumState, WebSocketUpgrade, }, http::StatusCode, response::IntoResponse, @@ -11,8 +11,14 @@ use axum::{ use serde_json::Value; use std::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::{daemon_socket_path, normalize_daemon_path, DaemonGatewayConfig}, + emitter::WsBroadcastEmitter, + maintainer::MaintainerScheduler, + shell_env, skills, + state::AppState, + status_socket, }; use tokio::sync::broadcast; @@ -58,6 +64,7 @@ async fn main() { .route("/api/create_session", post(create_session)) .route("/api/list_archived_projects", post(list_archived_projects)) .route("/api/merge_session_branch", post(merge_session_branch)) + .route("/api/daemon/{*path}", get(daemon_gateway)) .route("/api/read_daemon_token", post(read_daemon_token)) .route("/api/log_frontend_error", post(log_frontend_error)) .route("/api/get_agents_md", post(get_agents_md)) @@ -482,6 +489,26 @@ async fn read_daemon_token() -> Result, (StatusCode, String)> { Ok(Json(Value::String(token))) } +async fn daemon_gateway( + AxumState(state): AxumState>, + OriginalUri(uri): OriginalUri, +) -> Result, (StatusCode, String)> { + let daemon_path = + normalize_daemon_path(uri.path()).map_err(|e| (StatusCode::BAD_REQUEST, 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 }); + + Ok(Json(serde_json::json!({ + "path": daemon_path, + "status": "gateway-ready" + }))) +} + async fn log_frontend_error(Json(args): Json) -> Result, (StatusCode, String)> { let message = args["message"].as_str().unwrap_or(""); eprintln!("[FRONTEND] {message}"); diff --git a/server/tests/daemon_gateway.rs b/server/tests/daemon_gateway.rs new file mode 100644 index 0000000..e700ecd --- /dev/null +++ b/server/tests/daemon_gateway.rs @@ -0,0 +1,26 @@ +use the_controller_lib::daemon_gateway::{daemon_socket_path, DaemonGatewayConfig}; + +#[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!( + 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/daemonish").is_err()); + assert!( + the_controller_lib::daemon_gateway::normalize_daemon_path("/api/daemon/../projects") + .is_err() + ); +} From 1e113da5100e749de179b70bcdd97b981d4b183f Mon Sep 17 00:00:00 2001 From: Noel Kwan Date: Thu, 30 Apr 2026 08:01:10 +0800 Subject: [PATCH 03/35] fix: harden daemon gateway path handling --- server/src/daemon_gateway.rs | 51 +++++++++++++++++++++++++++++++++- server/src/main.rs | 12 ++++---- server/tests/daemon_gateway.rs | 27 ++++++++++++++++-- 3 files changed, 81 insertions(+), 9 deletions(-) diff --git a/server/src/daemon_gateway.rs b/server/src/daemon_gateway.rs index cc01387..98bcfee 100644 --- a/server/src/daemon_gateway.rs +++ b/server/src/daemon_gateway.rs @@ -1,5 +1,7 @@ use std::path::PathBuf; +use serde_json::Value; + #[derive(Debug, Clone)] pub struct DaemonGatewayConfig { pub state_dir: PathBuf, @@ -16,7 +18,8 @@ pub fn normalize_daemon_path(path: &str) -> Result { if !rest.is_empty() && !rest.starts_with('/') { return Err("path must stay under /api/daemon".to_string()); } - if rest.contains("..") { + 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() { @@ -25,3 +28,49 @@ pub fn normalize_daemon_path(path: &str) -> Result { rest.to_string() }) } + +pub fn daemon_gateway_placeholder_response(daemon_path: &str) -> Result { + if daemon_path != "/health" { + return Err("daemon gateway proxy is not implemented yet".to_string()); + } + + Ok(serde_json::json!({ + "path": daemon_path, + "status": "gateway-ready" + })) +} + +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/main.rs b/server/src/main.rs index 5afc7b6..564ef4a 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -13,7 +13,10 @@ use std::sync::Arc; use the_controller_lib::{ auto_worker::AutoWorkerScheduler, cli_install, commands, config, - daemon_gateway::{daemon_socket_path, normalize_daemon_path, DaemonGatewayConfig}, + daemon_gateway::{ + daemon_gateway_placeholder_response, daemon_socket_path, normalize_daemon_path, + DaemonGatewayConfig, + }, emitter::WsBroadcastEmitter, maintainer::MaintainerScheduler, shell_env, skills, @@ -502,11 +505,10 @@ async fn daemon_gateway( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .base_dir(); let _socket_path = daemon_socket_path(&DaemonGatewayConfig { state_dir }); + let response = daemon_gateway_placeholder_response(&daemon_path) + .map_err(|e| (StatusCode::NOT_IMPLEMENTED, e))?; - Ok(Json(serde_json::json!({ - "path": daemon_path, - "status": "gateway-ready" - }))) + Ok(Json(response)) } async fn log_frontend_error(Json(args): Json) -> Result, (StatusCode, String)> { diff --git a/server/tests/daemon_gateway.rs b/server/tests/daemon_gateway.rs index e700ecd..e55dc71 100644 --- a/server/tests/daemon_gateway.rs +++ b/server/tests/daemon_gateway.rs @@ -1,4 +1,6 @@ -use the_controller_lib::daemon_gateway::{daemon_socket_path, DaemonGatewayConfig}; +use the_controller_lib::daemon_gateway::{ + daemon_gateway_placeholder_response, daemon_socket_path, DaemonGatewayConfig, +}; #[test] fn daemon_socket_path_uses_controller_state_dir() { @@ -12,8 +14,9 @@ fn daemon_socket_path_uses_controller_state_dir() { #[test] fn gateway_paths_must_stay_under_api_daemon() { - assert!( - the_controller_lib::daemon_gateway::normalize_daemon_path("/api/daemon/profiles").is_ok() + 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() @@ -23,4 +26,22 @@ fn gateway_paths_must_stay_under_api_daemon() { 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_placeholder_only_succeeds_for_health() { + let response = daemon_gateway_placeholder_response("/health").unwrap(); + assert_eq!(response["path"], "/health"); + assert_eq!(response["status"], "gateway-ready"); + + let error = daemon_gateway_placeholder_response("/profiles").unwrap_err(); + assert_eq!(error, "daemon gateway proxy is not implemented yet"); } From 65c22636425f08152f16e9f73be9899c88bb5b70 Mon Sep 17 00:00:00 2001 From: Noel Kwan Date: Thu, 30 Apr 2026 08:07:12 +0800 Subject: [PATCH 04/35] feat: route daemon client through controller gateway --- src/lib/daemon/client.test.ts | 22 ++++++++++++++-------- src/lib/daemon/client.ts | 6 +----- src/lib/daemon/store.svelte.ts | 16 ++-------------- src/lib/daemon/store.test.ts | 34 +++++++++++++++------------------- src/lib/daemon/stream.test.ts | 24 ++++++++++++++---------- 5 files changed, 46 insertions(+), 56 deletions(-) diff --git a/src/lib/daemon/client.test.ts b/src/lib/daemon/client.test.ts index 134c63e..699789b 100644 --- a/src/lib/daemon/client.test.ts +++ b/src/lib/daemon/client.test.ts @@ -17,20 +17,26 @@ 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, diff --git a/src/lib/daemon/client.ts b/src/lib/daemon/client.ts index cbb4bfd..ead8278 100644 --- a/src/lib/daemon/client.ts +++ b/src/lib/daemon/client.ts @@ -23,13 +23,12 @@ export interface SendMessageRequest { } 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 +72,6 @@ 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; } } diff --git a/src/lib/daemon/store.svelte.ts b/src/lib/daemon/store.svelte.ts index c7006fc..6be397d 100644 --- a/src/lib/daemon/store.svelte.ts +++ b/src/lib/daemon/store.svelte.ts @@ -1,13 +1,11 @@ -import { command } from "$lib/backend"; import { SvelteMap } from "svelte/reactivity"; import { DaemonClient } from "./client"; import type { 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; @@ -17,7 +15,6 @@ interface StoreState { } export const daemonStore = $state({ - token: null, reachable: false, client: null, sessions: new SvelteMap(), @@ -27,16 +24,7 @@ export const daemonStore = $state({ }); 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(); } diff --git a/src/lib/daemon/store.test.ts b/src/lib/daemon/store.test.ts index 8aa79fd..88e2be5 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,25 @@ 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); - }); - - it("sets reachable=false when read_daemon_token fails", async () => { - vi.doMock("$lib/backend", () => ({ - command: vi.fn(async () => { throw new Error("token missing"); }), - listen: () => () => {}, - })); - vi.stubGlobal("fetch", vi.fn()); - const { daemonStore, bootstrap } = await import("./store.svelte"); - await bootstrap(); - expect(daemonStore.reachable).toBe(false); - expect(daemonStore.token).toBeNull(); + expect(commandMock).not.toHaveBeenCalled(); }); }); 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"); From bace83fe6751f27eacb840f1f4fcce7a09aed991 Mon Sep 17 00:00:00 2001 From: Noel Kwan Date: Thu, 30 Apr 2026 08:13:10 +0800 Subject: [PATCH 05/35] fix: remove stale frontend daemon token copy --- src/lib/chat/ChatView.svelte | 1 - src/lib/chat/ChatWorkspace.test.ts | 5 +++-- src/lib/chat/DaemonEmptyState.svelte | 2 +- src/lib/chat/__mocks__/daemonStore.svelte.ts | 1 - 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/lib/chat/ChatView.svelte b/src/lib/chat/ChatView.svelte index b424b8f..04372c8 100644 --- a/src/lib/chat/ChatView.svelte +++ b/src/lib/chat/ChatView.svelte @@ -31,7 +31,6 @@ } showToast("Session no longer exists.", "error"); } else { - // Follow-up: 401 re-read token + retry once (design doc, not v1). showToast(`Failed to load session: ${c.message}`, "error"); } } diff --git a/src/lib/chat/ChatWorkspace.test.ts b/src/lib/chat/ChatWorkspace.test.ts index 79f2b2e..a8587ec 100644 --- a/src/lib/chat/ChatWorkspace.test.ts +++ b/src/lib/chat/ChatWorkspace.test.ts @@ -3,7 +3,6 @@ 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(), @@ -25,8 +24,10 @@ describe("ChatWorkspace", () => { 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 () => { 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..1bae3cf 100644 --- a/src/lib/chat/__mocks__/daemonStore.svelte.ts +++ b/src/lib/chat/__mocks__/daemonStore.svelte.ts @@ -8,7 +8,6 @@ export const daemonStore = $state({ sessions: new SvelteMap([["s1", { id: "s1", label: "Chat 1", agent: "claude", status: "running" }]]), transcripts: new SvelteMap(), activeSessionId: "s1", - token: "TOK", reachable: true, newChatTarget: null, }); From 5af30e116196153a093bbefea68b4d79dbc4236e Mon Sep 17 00:00:00 2001 From: Noel Kwan Date: Thu, 30 Apr 2026 11:57:01 +0800 Subject: [PATCH 06/35] feat: proxy daemon http over unix socket --- server/src/daemon_gateway.rs | 76 +++++++++++++++++++++++---- server/src/main.rs | 56 ++++++++++++++++---- server/tests/daemon_gateway.rs | 94 +++++++++++++++++++++++++++++++--- 3 files changed, 199 insertions(+), 27 deletions(-) diff --git a/server/src/daemon_gateway.rs b/server/src/daemon_gateway.rs index 98bcfee..4c59a20 100644 --- a/server/src/daemon_gateway.rs +++ b/server/src/daemon_gateway.rs @@ -1,12 +1,19 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; -use serde_json::Value; +use axum::{body::Bytes, http::Method}; #[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") } @@ -29,15 +36,66 @@ pub fn normalize_daemon_path(path: &str) -> Result { }) } -pub fn daemon_gateway_placeholder_response(daemon_path: &str) -> Result { - if daemon_path != "/health" { - return Err("daemon gateway proxy is not implemented yet".to_string()); +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, + }) +} - Ok(serde_json::json!({ - "path": daemon_path, - "status": "gateway-ready" - })) +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 { diff --git a/server/src/main.rs b/server/src/main.rs index 564ef4a..a41ab89 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,10 +1,11 @@ use axum::{ + body::Bytes, extract::{ ws::{Message, WebSocket}, OriginalUri, State as AxumState, WebSocketUpgrade, }, - http::StatusCode, - response::IntoResponse, + http::{header, HeaderMap, HeaderValue, Method, StatusCode}, + response::{IntoResponse, Response}, routing::{get, post}, Json, Router, }; @@ -14,7 +15,7 @@ use the_controller_lib::{ auto_worker::AutoWorkerScheduler, cli_install, commands, config, daemon_gateway::{ - daemon_gateway_placeholder_response, daemon_socket_path, normalize_daemon_path, + daemon_socket_path, forward_http_with_content_type, normalize_daemon_path, DaemonGatewayConfig, }, emitter::WsBroadcastEmitter, @@ -67,7 +68,13 @@ async fn main() { .route("/api/create_session", post(create_session)) .route("/api/list_archived_projects", post(list_archived_projects)) .route("/api/merge_session_branch", post(merge_session_branch)) - .route("/api/daemon/{*path}", get(daemon_gateway)) + .route( + "/api/daemon/{*path}", + get(daemon_gateway) + .post(daemon_gateway) + .patch(daemon_gateway) + .delete(daemon_gateway), + ) .route("/api/read_daemon_token", post(read_daemon_token)) .route("/api/log_frontend_error", post(log_frontend_error)) .route("/api/get_agents_md", post(get_agents_md)) @@ -495,20 +502,49 @@ async fn read_daemon_token() -> Result, (StatusCode, String)> { async fn daemon_gateway( AxumState(state): AxumState>, OriginalUri(uri): OriginalUri, -) -> Result, (StatusCode, String)> { - let daemon_path = + method: Method, + headers: HeaderMap, + body: Bytes, +) -> Result { + 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 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 response = daemon_gateway_placeholder_response(&daemon_path) - .map_err(|e| (StatusCode::NOT_IMPLEMENTED, e))?; + 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 = + forward_http_with_content_type(&socket_path, method, daemon_path, body, content_type) + .await + .map_err(|_| { + ( + StatusCode::BAD_GATEWAY, + "daemon gateway unavailable".to_string(), + ) + })?; + + 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); + } - Ok(Json(response)) + response + .body(axum::body::Body::from(daemon_response.body)) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())) } async fn log_frontend_error(Json(args): Json) -> Result, (StatusCode, String)> { diff --git a/server/tests/daemon_gateway.rs b/server/tests/daemon_gateway.rs index e55dc71..c5ece54 100644 --- a/server/tests/daemon_gateway.rs +++ b/server/tests/daemon_gateway.rs @@ -1,5 +1,11 @@ +use axum::{body::Bytes, http::Method}; use the_controller_lib::daemon_gateway::{ - daemon_gateway_placeholder_response, daemon_socket_path, DaemonGatewayConfig, + daemon_socket_path, forward_http_for_test, forward_http_with_content_type, DaemonGatewayConfig, +}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::UnixListener, + task::JoinHandle, }; #[test] @@ -36,12 +42,84 @@ fn gateway_paths_must_stay_under_api_daemon() { .is_err()); } -#[test] -fn gateway_placeholder_only_succeeds_for_health() { - let response = daemon_gateway_placeholder_response("/health").unwrap(); - assert_eq!(response["path"], "/health"); - assert_eq!(response["status"], "gateway-ready"); +#[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(); +} + +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 mut request = [0; 1024]; + let Ok(read) = stream.read(&mut request).await else { + return; + }; + let request = String::from_utf8_lossy(&request[..read]); + let response = if request.starts_with("GET /health HTTP/") { + "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; + }); + } + }) +} - let error = daemon_gateway_placeholder_response("/profiles").unwrap_err(); - assert_eq!(error, "daemon gateway proxy is not implemented yet"); +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 mut request = [0; 2048]; + let Ok(read) = stream.read(&mut request).await else { + return; + }; + let request = String::from_utf8_lossy(&request[..read]); + let content_type = request + .lines() + .find_map(|line| line.strip_prefix("content-type: ")) + .unwrap_or(""); + 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; + }) } From 2e7cca0814e61d599a3474f8b55fbc549dbb85c1 Mon Sep 17 00:00:00 2001 From: Noel Kwan Date: Thu, 30 Apr 2026 12:02:38 +0800 Subject: [PATCH 07/35] fix: remove daemon token gateway route --- e2e/specs/chat-mode.spec.ts | 9 +++---- server/src/commands.rs | 1 - server/src/commands/daemon.rs | 48 ---------------------------------- server/src/main.rs | 8 ------ server/tests/daemon_gateway.rs | 10 +++++++ 5 files changed, 14 insertions(+), 62 deletions(-) delete mode 100644 server/src/commands/daemon.rs diff --git a/e2e/specs/chat-mode.spec.ts b/e2e/specs/chat-mode.spec.ts index c878f62..40529b7 100644 --- a/e2e/specs/chat-mode.spec.ts +++ b/e2e/specs/chat-mode.spec.ts @@ -15,9 +15,8 @@ import { spawn, type ChildProcess } from "node:child_process"; // // 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. +// paths below. Browser code reaches the daemon only through same-origin +// `/api/daemon/...` routes; daemon token and socket details stay server-side. const DAEMON_REPO = "/Users/noelkwan/projects/the-controller-daemon"; const DAEMON_BIN = `${DAEMON_REPO}/target/release/the-controller-daemon`; @@ -39,8 +38,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, }); diff --git a/server/src/commands.rs b/server/src/commands.rs index 9f59f3c..aa1bfc2 100644 --- a/server/src/commands.rs +++ b/server/src/commands.rs @@ -8,7 +8,6 @@ use crate::state::AppState; use crate::token_usage::{self, TokenDataPoint}; use crate::worktree::WorktreeManager; -pub mod daemon; pub mod github; pub mod kanban; 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/main.rs b/server/src/main.rs index a41ab89..029fdb5 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -75,7 +75,6 @@ async fn main() { .patch(daemon_gateway) .delete(daemon_gateway), ) - .route("/api/read_daemon_token", post(read_daemon_token)) .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)) @@ -492,13 +491,6 @@ 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() - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; - Ok(Json(Value::String(token))) -} - async fn daemon_gateway( AxumState(state): AxumState>, OriginalUri(uri): OriginalUri, diff --git a/server/tests/daemon_gateway.rs b/server/tests/daemon_gateway.rs index c5ece54..4f3d935 100644 --- a/server/tests/daemon_gateway.rs +++ b/server/tests/daemon_gateway.rs @@ -42,6 +42,16 @@ fn gateway_paths_must_stay_under_api_daemon() { .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")); +} + #[tokio::test] async fn gateway_health_uses_unix_socket() { let tmp = tempfile::tempdir().unwrap(); From 35ca65e8774c90453982b58991b70b804960e21a Mon Sep 17 00:00:00 2001 From: Noel Kwan Date: Thu, 30 Apr 2026 12:10:48 +0800 Subject: [PATCH 08/35] test: cover daemon gateway route forwarding --- server/src/daemon_gateway.rs | 30 +++++- server/src/main.rs | 29 ++---- server/tests/daemon_gateway.rs | 167 ++++++++++++++++++++++++++++++--- 3 files changed, 193 insertions(+), 33 deletions(-) diff --git a/server/src/daemon_gateway.rs b/server/src/daemon_gateway.rs index 4c59a20..97ad087 100644 --- a/server/src/daemon_gateway.rs +++ b/server/src/daemon_gateway.rs @@ -1,6 +1,9 @@ use std::path::{Path, PathBuf}; -use axum::{body::Bytes, http::Method}; +use axum::{ + body::Bytes, + http::{Method, StatusCode}, +}; #[derive(Debug, Clone)] pub struct DaemonGatewayConfig { @@ -85,6 +88,31 @@ pub async fn forward_http_with_content_type( }) } +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, diff --git a/server/src/main.rs b/server/src/main.rs index 029fdb5..c1d5588 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -14,10 +14,7 @@ use std::sync::Arc; use the_controller_lib::{ auto_worker::AutoWorkerScheduler, cli_install, commands, config, - daemon_gateway::{ - daemon_socket_path, forward_http_with_content_type, normalize_daemon_path, - DaemonGatewayConfig, - }, + daemon_gateway::{daemon_socket_path, proxy_http_gateway, DaemonGatewayConfig}, emitter::WsBroadcastEmitter, maintainer::MaintainerScheduler, shell_env, skills, @@ -498,12 +495,6 @@ async fn daemon_gateway( headers: HeaderMap, body: Bytes, ) -> Result { - 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 state_dir = state .app .storage @@ -515,15 +506,15 @@ async fn daemon_gateway( .get(header::CONTENT_TYPE) .and_then(|value| value.to_str().ok()) .map(ToOwned::to_owned); - let daemon_response = - forward_http_with_content_type(&socket_path, method, daemon_path, body, content_type) - .await - .map_err(|_| { - ( - StatusCode::BAD_GATEWAY, - "daemon gateway unavailable".to_string(), - ) - })?; + 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()))?; diff --git a/server/tests/daemon_gateway.rs b/server/tests/daemon_gateway.rs index 4f3d935..8f1018f 100644 --- a/server/tests/daemon_gateway.rs +++ b/server/tests/daemon_gateway.rs @@ -1,6 +1,11 @@ -use axum::{body::Bytes, http::Method}; +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, DaemonGatewayConfig, + daemon_socket_path, forward_http_for_test, forward_http_with_content_type, proxy_http_gateway, + DaemonGatewayConfig, }; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, @@ -86,6 +91,52 @@ async fn gateway_post_forwards_request_content_type() { 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 { @@ -94,12 +145,10 @@ async fn serve_uds_health(socket: &std::path::Path) -> JoinHandle<()> { return; }; tokio::spawn(async move { - let mut request = [0; 1024]; - let Ok(read) = stream.read(&mut request).await else { + let Some(request) = read_http_request(&mut stream).await else { return; }; - let request = String::from_utf8_lossy(&request[..read]); - let response = if request.starts_with("GET /health HTTP/") { + 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" @@ -116,15 +165,10 @@ async fn serve_uds_content_type_echo(socket: &std::path::Path) -> JoinHandle<()> let Ok((mut stream, _addr)) = listener.accept().await else { return; }; - let mut request = [0; 2048]; - let Ok(read) = stream.read(&mut request).await else { + let Some(request) = read_http_request(&mut stream).await else { return; }; - let request = String::from_utf8_lossy(&request[..read]); - let content_type = request - .lines() - .find_map(|line| line.strip_prefix("content-type: ")) - .unwrap_or(""); + 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(), @@ -133,3 +177,100 @@ async fn serve_uds_content_type_echo(socket: &std::path::Path) -> JoinHandle<()> 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") +} From 08ec38b179a06bcb7cf001a40062c2a687431f33 Mon Sep 17 00:00:00 2001 From: Noel Kwan Date: Thu, 30 Apr 2026 12:17:52 +0800 Subject: [PATCH 09/35] test: gate daemon reachable e2e harness --- e2e/specs/chat-mode.spec.ts | 37 +++++++++++++------------------ src/lib/web-backend-audit.test.ts | 11 +++++++++ 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/e2e/specs/chat-mode.spec.ts b/e2e/specs/chat-mode.spec.ts index 40529b7..d2e8469 100644 --- a/e2e/specs/chat-mode.spec.ts +++ b/e2e/specs/chat-mode.spec.ts @@ -1,7 +1,5 @@ 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"; // User story: @@ -13,10 +11,11 @@ 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. Browser code reaches the daemon only through same-origin -// `/api/daemon/...` routes; daemon token and socket details stay server-side. +// 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 and TCD_STATE_DIR=. Playwright starts +// the Controller before this spec runs, so the Controller and daemon must +// inherit the same 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`; @@ -57,26 +56,29 @@ 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 sharedStateDir = process.env.TCD_STATE_DIR; +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 and TCD_STATE_DIR shared with the Controller. " + + "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", @@ -91,13 +93,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/src/lib/web-backend-audit.test.ts b/src/lib/web-backend-audit.test.ts index 9e14f92..a0164be 100644 --- a/src/lib/web-backend-audit.test.ts +++ b/src/lib/web-backend-audit.test.ts @@ -72,4 +72,15 @@ 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("TCD_STATE_DIR"); + expect(source).not.toMatch( + /test\.skip\(\s*!\s*daemonBinariesPresent[\s,]/, + ); + expect(source).not.toContain("mkdtempSync"); + }); }); From 489b519e757580b7302e7b41c6bcae03bd741b87 Mon Sep 17 00:00:00 2001 From: Noel Kwan Date: Thu, 30 Apr 2026 12:24:46 +0800 Subject: [PATCH 10/35] fix: align controller and daemon e2e state dirs --- e2e/specs/chat-mode.spec.ts | 13 +++++++---- server/src/storage.rs | 39 +++++++++++++++++++++++++++++++ src/lib/web-backend-audit.test.ts | 2 ++ 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/e2e/specs/chat-mode.spec.ts b/e2e/specs/chat-mode.spec.ts index d2e8469..5889c68 100644 --- a/e2e/specs/chat-mode.spec.ts +++ b/e2e/specs/chat-mode.spec.ts @@ -13,9 +13,10 @@ import { spawn, type ChildProcess } from "node:child_process"; // // 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 and TCD_STATE_DIR=. Playwright starts -// the Controller before this spec runs, so the Controller and daemon must -// inherit the same state dir for `/api/daemon/...` to target the same UDS. +// 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 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`; @@ -60,7 +61,9 @@ test("chat mode shows DaemonEmptyState when daemon is unreachable", async ({ pag // --------------------------------------------------------------------------- const daemonBinariesPresent = existsSync(DAEMON_BIN) && existsSync(FAKE_AGENT_BIN); const daemonReachableHarnessEnabled = process.env.TCD_E2E_DAEMON_REACHABLE === "1"; -const sharedStateDir = process.env.TCD_STATE_DIR; +const controllerStateDir = process.env.THE_CONTROLLER_STATE_DIR; +const daemonStateDir = process.env.TCD_STATE_DIR; +const sharedStateDir = controllerStateDir === daemonStateDir ? daemonStateDir : undefined; const daemonReachableEnabled = daemonBinariesPresent && daemonReachableHarnessEnabled && Boolean(sharedStateDir); @@ -68,7 +71,7 @@ test.describe("chat mode with daemon reachable", () => { test.skip( !daemonReachableEnabled, "daemon reachable e2e requires binaries plus " + - "TCD_E2E_DAEMON_REACHABLE=1 and TCD_STATE_DIR shared with the Controller. " + + "TCD_E2E_DAEMON_REACHABLE=1 with matching THE_CONTROLLER_STATE_DIR and TCD_STATE_DIR. " + "Task 21 owns the full daemon harness.", ); 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/src/lib/web-backend-audit.test.ts b/src/lib/web-backend-audit.test.ts index a0164be..17977c0 100644 --- a/src/lib/web-backend-audit.test.ts +++ b/src/lib/web-backend-audit.test.ts @@ -77,7 +77,9 @@ describe("web backend migration audit", () => { 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).not.toMatch( /test\.skip\(\s*!\s*daemonBinariesPresent[\s,]/, ); From b114df1c2adcf41da585f2b3fa98f49c23759de1 Mon Sep 17 00:00:00 2001 From: Noel Kwan Date: Thu, 30 Apr 2026 12:31:12 +0800 Subject: [PATCH 11/35] test: require absolute daemon e2e state dirs --- e2e/specs/chat-mode.spec.ts | 20 ++++++++++++++------ src/lib/web-backend-audit.test.ts | 2 ++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/e2e/specs/chat-mode.spec.ts b/e2e/specs/chat-mode.spec.ts index 5889c68..31c4087 100644 --- a/e2e/specs/chat-mode.spec.ts +++ b/e2e/specs/chat-mode.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from "@playwright/test"; 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,10 +14,10 @@ import { spawn, type ChildProcess } from "node:child_process"; // // 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 state dir -// for `/api/daemon/...` to target the same UDS. +// 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`; @@ -63,7 +64,14 @@ const daemonBinariesPresent = existsSync(DAEMON_BIN) && existsSync(FAKE_AGENT_BI 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 ? daemonStateDir : undefined; +const sharedStateDir = + controllerStateDir && + daemonStateDir && + isAbsolute(controllerStateDir) && + isAbsolute(daemonStateDir) && + controllerStateDir === daemonStateDir + ? daemonStateDir + : undefined; const daemonReachableEnabled = daemonBinariesPresent && daemonReachableHarnessEnabled && Boolean(sharedStateDir); @@ -71,7 +79,7 @@ test.describe("chat mode with daemon reachable", () => { test.skip( !daemonReachableEnabled, "daemon reachable e2e requires binaries plus " + - "TCD_E2E_DAEMON_REACHABLE=1 with matching THE_CONTROLLER_STATE_DIR and TCD_STATE_DIR. " + + "TCD_E2E_DAEMON_REACHABLE=1 with matching absolute THE_CONTROLLER_STATE_DIR and TCD_STATE_DIR. " + "Task 21 owns the full daemon harness.", ); diff --git a/src/lib/web-backend-audit.test.ts b/src/lib/web-backend-audit.test.ts index 17977c0..a03e205 100644 --- a/src/lib/web-backend-audit.test.ts +++ b/src/lib/web-backend-audit.test.ts @@ -80,6 +80,8 @@ describe("web backend migration audit", () => { 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,]/, ); From 8fdc4c4e6695e8cdb8d882660d381a42b3a29e6e Mon Sep 17 00:00:00 2001 From: Noel Kwan Date: Thu, 30 Apr 2026 12:38:10 +0800 Subject: [PATCH 12/35] fix: remove permissive cors from daemon gateway --- server/Cargo.lock | 1 - server/Cargo.toml | 1 - server/src/main.rs | 2 -- server/tests/daemon_gateway.rs | 9 +++++++++ 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/server/Cargo.lock b/server/Cargo.lock index a967241..b735744 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1806,7 +1806,6 @@ dependencies = [ "serde_json", "tempfile", "tokio", - "tower-http", "uuid", ] diff --git a/server/Cargo.toml b/server/Cargo.toml index 74c4ff5..27a8c22 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -25,7 +25,6 @@ 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"] } [[bin]] name = "the-controller-server" diff --git a/server/src/main.rs b/server/src/main.rs index c1d5588..6285972 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -23,7 +23,6 @@ use the_controller_lib::{ }; use tokio::sync::broadcast; -use tower_http::cors::CorsLayer; struct ServerState { app: Arc, @@ -135,7 +134,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") { diff --git a/server/tests/daemon_gateway.rs b/server/tests/daemon_gateway.rs index 8f1018f..0ce329b 100644 --- a/server/tests/daemon_gateway.rs +++ b/server/tests/daemon_gateway.rs @@ -57,6 +57,15 @@ fn gateway_does_not_expose_daemon_token_route() { 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(); From 4d2a2e40cb250a41158a44f26bc455204cb2a77c Mon Sep 17 00:00:00 2001 From: Noel Kwan Date: Thu, 30 Apr 2026 12:50:00 +0800 Subject: [PATCH 13/35] feat: proxy daemon streams through controller --- server/Cargo.lock | 1 + server/Cargo.toml | 1 + server/src/daemon_gateway.rs | 29 +++++++ server/src/main.rs | 138 +++++++++++++++++++++++++++++- server/tests/daemon_gateway_ws.rs | 78 +++++++++++++++++ 5 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 server/tests/daemon_gateway_ws.rs diff --git a/server/Cargo.lock b/server/Cargo.lock index b735744..38a279a 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1806,6 +1806,7 @@ dependencies = [ "serde_json", "tempfile", "tokio", + "tokio-tungstenite", "uuid", ] diff --git a/server/Cargo.toml b/server/Cargo.toml index 27a8c22..2e5140d 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -25,6 +25,7 @@ libc = "0.2" reqwest = { version = "0.12", features = ["json", "stream"] } futures-util = "0.3" axum = { version = "0.8", features = ["ws"] } +tokio-tungstenite = "0.28" [[bin]] name = "the-controller-server" diff --git a/server/src/daemon_gateway.rs b/server/src/daemon_gateway.rs index 97ad087..d2a7a6a 100644 --- a/server/src/daemon_gateway.rs +++ b/server/src/daemon_gateway.rs @@ -4,6 +4,8 @@ use axum::{ body::Bytes, http::{Method, StatusCode}, }; +use tokio::net::UnixStream; +use tokio_tungstenite::{client_async, WebSocketStream}; #[derive(Debug, Clone)] pub struct DaemonGatewayConfig { @@ -39,6 +41,33 @@ pub fn normalize_daemon_path(path: &str) -> Result { }) } +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 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, diff --git a/server/src/main.rs b/server/src/main.rs index 6285972..d6cb587 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -14,7 +14,10 @@ use std::sync::Arc; use the_controller_lib::{ auto_worker::AutoWorkerScheduler, cli_install, commands, config, - daemon_gateway::{daemon_socket_path, proxy_http_gateway, DaemonGatewayConfig}, + daemon_gateway::{ + connect_daemon_websocket, daemon_socket_path, is_daemon_stream_path, normalize_daemon_path, + proxy_http_gateway, DaemonGatewayConfig, + }, emitter::WsBroadcastEmitter, maintainer::MaintainerScheduler, shell_env, skills, @@ -22,7 +25,12 @@ use the_controller_lib::{ status_socket, }; +use futures_util::{SinkExt, StreamExt}; use tokio::sync::broadcast; +use tokio_tungstenite::tungstenite::{ + protocol::{frame::coding::CloseCode, CloseFrame as TungsteniteCloseFrame}, + Message as TungsteniteMessage, +}; struct ServerState { app: Arc, @@ -64,6 +72,11 @@ async fn main() { .route("/api/create_session", post(create_session)) .route("/api/list_archived_projects", post(list_archived_projects)) .route("/api/merge_session_branch", post(merge_session_branch)) + .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) @@ -528,6 +541,129 @@ async fn daemon_gateway( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())) } +async fn daemon_stream_gateway( + ws: WebSocketUpgrade, + AxumState(state): AxumState>, + OriginalUri(uri): OriginalUri, +) -> Result { + if !is_daemon_stream_path(uri.path()) { + return Err(( + StatusCode::BAD_REQUEST, + "path must be a daemon stream route".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(|_| { + ( + StatusCode::BAD_GATEWAY, + "daemon gateway unavailable".to_string(), + ) + })?; + + Ok(ws + .on_upgrade(move |socket| proxy_daemon_stream(socket, daemon_ws)) + .into_response()) +} + +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)> { let message = args["message"].as_str().unwrap_or(""); eprintln!("[FRONTEND] {message}"); diff --git a/server/tests/daemon_gateway_ws.rs b/server/tests/daemon_gateway_ws.rs new file mode 100644 index 0000000..98af712 --- /dev/null +++ b/server/tests/daemon_gateway_ws.rs @@ -0,0 +1,78 @@ +use futures_util::{SinkExt, StreamExt}; +use the_controller_lib::daemon_gateway::connect_daemon_websocket; +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)); +} + +#[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(); +} From 9c8cb55de271ed5946278b302986b455ab4ba4cf Mon Sep 17 00:00:00 2001 From: Noel Kwan Date: Thu, 30 Apr 2026 13:01:21 +0800 Subject: [PATCH 14/35] fix: validate daemon stream origins --- server/src/daemon_gateway.rs | 66 +++++++++++++++++++++++++++++++ server/src/main.rs | 31 ++++++++++++++- server/tests/daemon_gateway_ws.rs | 36 ++++++++++++++++- src/lib/web-backend-audit.test.ts | 7 ++++ vite.config.ts | 6 ++- 5 files changed, 142 insertions(+), 4 deletions(-) diff --git a/server/src/daemon_gateway.rs b/server/src/daemon_gateway.rs index d2a7a6a..e51e5fd 100644 --- a/server/src/daemon_gateway.rs +++ b/server/src/daemon_gateway.rs @@ -53,6 +53,72 @@ pub fn is_daemon_stream_path(path: &str) -> bool { ) } +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_known_dev_frontend_origin(&origin, &request_authority) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct OriginParts { + scheme: String, + 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 { + scheme: scheme.to_string(), + 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_known_dev_frontend_origin(origin: &OriginParts, request_authority: &AuthorityParts) -> bool { + origin.scheme == "http" + && origin.authority.port == Some(1420) + && is_loopback_host(&origin.authority.host) + && is_loopback_host(&request_authority.host) +} + +fn is_loopback_host(host: &str) -> bool { + matches!(host, "localhost" | "127.0.0.1" | "::1") +} + pub async fn connect_daemon_websocket( socket_path: &Path, daemon_path: &str, diff --git a/server/src/main.rs b/server/src/main.rs index d6cb587..a91ac88 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -15,8 +15,8 @@ use the_controller_lib::{ auto_worker::AutoWorkerScheduler, cli_install, commands, config, daemon_gateway::{ - connect_daemon_websocket, daemon_socket_path, is_daemon_stream_path, normalize_daemon_path, - proxy_http_gateway, DaemonGatewayConfig, + 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, @@ -545,6 +545,7 @@ 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(( @@ -553,6 +554,15 @@ async fn daemon_stream_gateway( )); } + 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 @@ -581,6 +591,23 @@ async fn daemon_stream_gateway( .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, diff --git a/server/tests/daemon_gateway_ws.rs b/server/tests/daemon_gateway_ws.rs index 98af712..e066b70 100644 --- a/server/tests/daemon_gateway_ws.rs +++ b/server/tests/daemon_gateway_ws.rs @@ -1,5 +1,7 @@ use futures_util::{SinkExt, StreamExt}; -use the_controller_lib::daemon_gateway::connect_daemon_websocket; +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, @@ -38,6 +40,38 @@ fn explicit_stream_routes_can_coexist_with_daemon_catch_all() { .route("/api/daemon/{*path}", axum::routing::get(ok)); } +#[test] +fn stream_origin_policy_allows_native_same_origin_and_dev_proxy_origins() { + 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:1420"), + Some("127.0.0.1:3001") + )); +} + +#[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 + )); +} + #[tokio::test] async fn daemon_websocket_connects_over_unix_socket_and_preserves_query() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/lib/web-backend-audit.test.ts b/src/lib/web-backend-audit.test.ts index a03e205..20464b1 100644 --- a/src/lib/web-backend-audit.test.ts +++ b/src/lib/web-backend-audit.test.ts @@ -87,4 +87,11 @@ describe("web backend migration audit", () => { ); 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"); + }); }); diff --git a/vite.config.ts b/vite.config.ts index ce52871..20d525a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -53,7 +53,11 @@ export default defineConfig(async () => ({ ignored: ["**/server/**"], }, proxy: { - "/api": `http://localhost:${axumPort}`, + "/api": { + target: `http://localhost:${axumPort}`, + changeOrigin: true, + ws: true, + }, "/ws": { target: `ws://localhost:${axumPort}`, ws: true, From 1b1e17c50f175179871c4ae9f6f2dc4f19893f09 Mon Sep 17 00:00:00 2001 From: Noel Kwan Date: Thu, 30 Apr 2026 13:08:23 +0800 Subject: [PATCH 15/35] fix: preserve daemon stream proxy host --- server/src/daemon_gateway.rs | 14 -------------- server/tests/daemon_gateway_ws.rs | 10 +++++++--- src/lib/web-backend-audit.test.ts | 1 + vite.config.ts | 1 - 4 files changed, 8 insertions(+), 18 deletions(-) diff --git a/server/src/daemon_gateway.rs b/server/src/daemon_gateway.rs index e51e5fd..65abde2 100644 --- a/server/src/daemon_gateway.rs +++ b/server/src/daemon_gateway.rs @@ -65,12 +65,10 @@ pub fn is_allowed_daemon_stream_origin(origin: Option<&str>, host: Option<&str>) }; origin.authority == request_authority - || is_known_dev_frontend_origin(&origin, &request_authority) } #[derive(Debug, Clone, PartialEq, Eq)] struct OriginParts { - scheme: String, authority: AuthorityParts, } @@ -94,7 +92,6 @@ fn parse_origin(origin: &str) -> Option { } Some(OriginParts { - scheme: scheme.to_string(), authority: parse_authority(authority)?, }) } @@ -108,17 +105,6 @@ fn parse_authority(authority: &str) -> Option { }) } -fn is_known_dev_frontend_origin(origin: &OriginParts, request_authority: &AuthorityParts) -> bool { - origin.scheme == "http" - && origin.authority.port == Some(1420) - && is_loopback_host(&origin.authority.host) - && is_loopback_host(&request_authority.host) -} - -fn is_loopback_host(host: &str) -> bool { - matches!(host, "localhost" | "127.0.0.1" | "::1") -} - pub async fn connect_daemon_websocket( socket_path: &Path, daemon_path: &str, diff --git a/server/tests/daemon_gateway_ws.rs b/server/tests/daemon_gateway_ws.rs index e066b70..dc1d592 100644 --- a/server/tests/daemon_gateway_ws.rs +++ b/server/tests/daemon_gateway_ws.rs @@ -41,7 +41,7 @@ fn explicit_stream_routes_can_coexist_with_daemon_catch_all() { } #[test] -fn stream_origin_policy_allows_native_same_origin_and_dev_proxy_origins() { +fn stream_origin_policy_allows_native_and_same_origin_requests() { assert!(is_allowed_daemon_stream_origin( None, Some("localhost:3001") @@ -51,8 +51,8 @@ fn stream_origin_policy_allows_native_same_origin_and_dev_proxy_origins() { Some("localhost:3001") )); assert!(is_allowed_daemon_stream_origin( - Some("http://127.0.0.1:1420"), - Some("127.0.0.1:3001") + Some("http://127.0.0.1:1934"), + Some("127.0.0.1:1934") )); } @@ -70,6 +70,10 @@ fn stream_origin_policy_rejects_foreign_or_malformed_origins() { Some("http://localhost:1420"), None )); + assert!(!is_allowed_daemon_stream_origin( + Some("http://localhost:1420"), + Some("localhost:3001") + )); } #[tokio::test] diff --git a/src/lib/web-backend-audit.test.ts b/src/lib/web-backend-audit.test.ts index 20464b1..3027844 100644 --- a/src/lib/web-backend-audit.test.ts +++ b/src/lib/web-backend-audit.test.ts @@ -93,5 +93,6 @@ describe("web backend migration audit", () => { 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 20d525a..65ac14e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -55,7 +55,6 @@ export default defineConfig(async () => ({ proxy: { "/api": { target: `http://localhost:${axumPort}`, - changeOrigin: true, ws: true, }, "/ws": { From c7802dfd1942581bc5f6c3af7c6e138b7a285720 Mon Sep 17 00:00:00 2001 From: Noel Kwan Date: Thu, 30 Apr 2026 13:13:27 +0800 Subject: [PATCH 16/35] fix: trust only local daemon stream origins --- server/src/daemon_gateway.rs | 6 +++++- server/tests/daemon_gateway_ws.rs | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/server/src/daemon_gateway.rs b/server/src/daemon_gateway.rs index 65abde2..529c050 100644 --- a/server/src/daemon_gateway.rs +++ b/server/src/daemon_gateway.rs @@ -64,7 +64,7 @@ pub fn is_allowed_daemon_stream_origin(origin: Option<&str>, host: Option<&str>) return false; }; - origin.authority == request_authority + origin.authority == request_authority && is_trusted_daemon_stream_host(&request_authority.host) } #[derive(Debug, Clone, PartialEq, Eq)] @@ -105,6 +105,10 @@ fn parse_authority(authority: &str) -> Option { }) } +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, diff --git a/server/tests/daemon_gateway_ws.rs b/server/tests/daemon_gateway_ws.rs index dc1d592..daedc4f 100644 --- a/server/tests/daemon_gateway_ws.rs +++ b/server/tests/daemon_gateway_ws.rs @@ -74,6 +74,10 @@ fn stream_origin_policy_rejects_foreign_or_malformed_origins() { Some("http://localhost:1420"), Some("localhost:3001") )); + assert!(!is_allowed_daemon_stream_origin( + Some("http://evil.example:3001"), + Some("evil.example:3001") + )); } #[tokio::test] From a997acedacc68654fecba7d7427ba0b29ae58176 Mon Sep 17 00:00:00 2001 From: Noel Kwan Date: Thu, 30 Apr 2026 13:25:07 +0800 Subject: [PATCH 17/35] feat: build controller workspace snapshots --- server/src/commands.rs | 71 ++++++++- server/src/main.rs | 208 ++++++++++++++++++++++++++- server/src/models.rs | 10 ++ server/tests/chat_workspace_links.rs | 128 +++++++++++++++++ 4 files changed, 415 insertions(+), 2 deletions(-) create mode 100644 server/tests/chat_workspace_links.rs diff --git a/server/src/commands.rs b/server/src/commands.rs index aa1bfc2..83ada95 100644 --- a/server/src/commands.rs +++ b/server/src/commands.rs @@ -3,7 +3,9 @@ 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; @@ -51,6 +53,45 @@ 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 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, @@ -586,6 +627,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/main.rs b/server/src/main.rs index a91ac88..2a5fe0e 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -10,7 +10,7 @@ use axum::{ 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, @@ -20,6 +20,7 @@ use the_controller_lib::{ }, emitter::WsBroadcastEmitter, maintainer::MaintainerScheduler, + models::{ChatWorkspaceSnapshot, GithubIssue}, shell_env, skills, state::AppState, status_socket, @@ -70,6 +71,7 @@ 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/daemon/chats/{id}/stream", get(daemon_stream_gateway)) @@ -499,6 +501,210 @@ async fn create_session( Ok(Json(Value::String(session_id))) } +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 chat_id = match args["chatId"].as_str() { + Some(chat_id) => { + validate_daemon_chat_id(chat_id)?; + chat_id.to_string() + } + None => { + let title = args["title"].as_str().unwrap_or("New chat"); + create_daemon_chat(&socket_path, &project_id, title).await? + } + }; + + let blocking_state = state.clone(); + let blocking_project_id = project_id.clone(); + let (session_id, workspace) = 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}"), + ) + })? + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + + 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; + return Err((status, append_cleanup_error(message, 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}")), + } +} + +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, 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/tests/chat_workspace_links.rs b/server/tests/chat_workspace_links.rs new file mode 100644 index 0000000..a442a60 --- /dev/null +++ b/server/tests/chat_workspace_links.rs @@ -0,0 +1,128 @@ +use serde_json::json; +use the_controller_lib::{ + commands::chat_workspace_snapshot, + 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 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("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, + } +} From b68b3db3bddc327d29788ab6a2d552b9a8c9234f Mon Sep 17 00:00:00 2001 From: Noel Kwan Date: Thu, 30 Apr 2026 13:32:52 +0800 Subject: [PATCH 18/35] fix: clean up created daemon chats --- server/src/commands.rs | 16 ++++++ server/src/main.rs | 74 +++++++++++++++++++++++++--- server/tests/chat_workspace_links.rs | 13 ++++- 3 files changed, 95 insertions(+), 8 deletions(-) diff --git a/server/src/commands.rs b/server/src/commands.rs index 83ada95..b3de9df 100644 --- a/server/src/commands.rs +++ b/server/src/commands.rs @@ -71,6 +71,22 @@ pub fn chat_workspace_snapshot( } } +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, diff --git a/server/src/main.rs b/server/src/main.rs index 2a5fe0e..c5f85c9 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -528,20 +528,40 @@ async fn create_chat_workspace( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .base_dir(); let socket_path = daemon_socket_path(&DaemonGatewayConfig { state_dir }); - let chat_id = match args["chatId"].as_str() { + + 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() + (chat_id.to_string(), None) } None => { let title = args["title"].as_str().unwrap_or("New chat"); - create_daemon_chat(&socket_path, &project_id, title).await? + 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_id, workspace) = tokio::task::spawn_blocking(move || { + let session_result = tokio::task::spawn_blocking(move || { commands::create_session_workspace_snapshot_impl( &blocking_state.app, blocking_project_id, @@ -558,15 +578,26 @@ async fn create_chat_workspace( StatusCode::INTERNAL_SERVER_ERROR, format!("Task failed: {e}"), ) - })? - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, 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; - return Err((status, append_cleanup_error(message, cleanup_error))); + 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))); } }; @@ -698,6 +729,35 @@ async fn cleanup_chat_workspace_session( } } +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})"), diff --git a/server/tests/chat_workspace_links.rs b/server/tests/chat_workspace_links.rs index a442a60..0362140 100644 --- a/server/tests/chat_workspace_links.rs +++ b/server/tests/chat_workspace_links.rs @@ -1,6 +1,6 @@ use serde_json::json; use the_controller_lib::{ - commands::chat_workspace_snapshot, + commands::{chat_workspace_snapshot, daemon_chat_cleanup_path}, models::{AutoWorkerConfig, ChatWorkspaceSnapshot, MaintainerConfig, Project, SessionConfig}, }; use uuid::Uuid; @@ -77,6 +77,15 @@ fn workspace_snapshot_helper_falls_back_to_repo_path_for_unborn_repo_sessions() 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"); @@ -89,6 +98,8 @@ fn controller_route_posts_workspace_snapshots_through_daemon_gateway() { .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")); } From 04915324844f3fa3c33328b40dd64ec191a5bff6 Mon Sep 17 00:00:00 2001 From: Noel Kwan Date: Thu, 30 Apr 2026 13:43:52 +0800 Subject: [PATCH 19/35] feat: model daemon chats and profiles in frontend --- src/lib/daemon/client.test.ts | 130 +++++++++++++++++++++++++++++++ src/lib/daemon/client.ts | 100 +++++++++++++++++++++++- src/lib/daemon/store.svelte.ts | 53 ++++++++++++- src/lib/daemon/store.test.ts | 114 +++++++++++++++++++++++++++ src/lib/daemon/types.test.ts | 106 ++++++++++++++++++++++++- src/lib/daemon/types.ts | 137 +++++++++++++++++++++++++++++++++ 6 files changed, 637 insertions(+), 3 deletions(-) diff --git a/src/lib/daemon/client.test.ts b/src/lib/daemon/client.test.ts index 699789b..2214de2 100644 --- a/src/lib/daemon/client.test.ts +++ b/src/lib/daemon/client.test.ts @@ -42,4 +42,134 @@ describe("DaemonClient", () => { 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("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 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 ead8278..e022a14 100644 --- a/src/lib/daemon/client.ts +++ b/src/lib/daemon/client.ts @@ -1,4 +1,18 @@ -import type { DaemonSession, EventRecord, Agent, Channel } from "./types"; +import type { + Agent, + AgentProfile, + AgentTurn, + AgentTurnTrace, + Channel, + Chat, + ChatMetrics, + ChatMessage, + ChatTranscriptEntry, + ChatWorkspaceLink, + DaemonSession, + EventRecord, + SendChatMessageRequest, +} from "./types"; export class DaemonHttpError extends Error { name = "DaemonHttpError"; @@ -22,6 +36,38 @@ 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 = "/api/daemon") {} @@ -74,4 +120,56 @@ export class DaemonClient { if (channels && channels.length) q.set("channels", channels.join(",")); return `${base}/sessions/${id}/stream?${q.toString()}`; } + + 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`); + } + 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 6be397d..e977134 100644 --- a/src/lib/daemon/store.svelte.ts +++ b/src/lib/daemon/store.svelte.ts @@ -1,6 +1,6 @@ import { SvelteMap } from "svelte/reactivity"; import { DaemonClient } from "./client"; -import type { DaemonSession } from "./types"; +import type { AgentProfile, AgentTurnTrace, Chat, ChatMetrics, ChatTranscriptEntry, DaemonSession } from "./types"; import { emptyTranscript, type TranscriptState } from "./reducer"; const BASE_URL = "/api/daemon"; @@ -11,6 +11,12 @@ interface StoreState { sessions: SvelteMap; transcripts: SvelteMap; activeSessionId: string | null; + profiles: SvelteMap; + chats: SvelteMap; + activeChatId: string | null; + chatTranscripts: SvelteMap; + chatSummaries: SvelteMap; + agentTraces: SvelteMap; newChatTarget: { projectId: string; projectCwd: string } | null; } @@ -20,6 +26,12 @@ export const daemonStore = $state({ sessions: new SvelteMap(), transcripts: new SvelteMap(), activeSessionId: null, + profiles: new SvelteMap(), + chats: new SvelteMap(), + activeChatId: null, + chatTranscripts: new SvelteMap(), + chatSummaries: new SvelteMap(), + agentTraces: new SvelteMap(), newChatTarget: null, }); @@ -54,3 +66,42 @@ 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 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 88e2be5..81ff9b4 100644 --- a/src/lib/daemon/store.test.ts +++ b/src/lib/daemon/store.test.ts @@ -37,4 +37,118 @@ describe("daemon store bootstrap", () => { expect(daemonStore.reachable).toBe(false); expect(commandMock).not.toHaveBeenCalled(); }); + + 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, loadProfiles, loadChats, loadChatTranscript, loadAgentTrace, loadChatMetrics } = await import("./store.svelte"); + daemonStore.client = { + listProfiles: vi.fn(async () => [profile]), + listChats: vi.fn(async () => [chat]), + readChatTranscript: vi.fn(async () => transcript), + getAgentTrace: vi.fn(async () => trace), + getChatMetrics: vi.fn(async () => metrics), + } as any; + + await loadProfiles(); + await loadChats(); + await loadChatTranscript("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.agentTraces.get("session-1")).toEqual(trace); + expect(daemonStore.chatSummaries.get("chat-1")).toEqual(metrics); + expect("token" in daemonStore).toBe(false); + }); }); diff --git a/src/lib/daemon/types.test.ts b/src/lib/daemon/types.test.ts index ba95103..a6bac73 100644 --- a/src/lib/daemon/types.test.ts +++ b/src/lib/daemon/types.test.ts @@ -1,6 +1,15 @@ import { describe, it, expect } from "vitest"; import fixture from "./__fixtures__/events.json"; -import type { EventRecord, OutboxEvent } from "./types"; +import type { + AgentProfile, + AgentTurnTrace, + Chat, + ChatMetrics, + ChatMessage, + EventRecord, + OutboxEvent, + RouteToken, +} from "./types"; describe("daemon event types", () => { it("parses fixture events without type errors", () => { @@ -19,4 +28,99 @@ 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[0]).toEqual({ kind: "shadow", handle: "critic", start: 0, end: 7 }); + }); + + 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"); + }); }); diff --git a/src/lib/daemon/types.ts b/src/lib/daemon/types.ts index 104eede..11986b5 100644 --- a/src/lib/daemon/types.ts +++ b/src/lib/daemon/types.ts @@ -1,6 +1,7 @@ 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; @@ -25,8 +26,144 @@ 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 | null; + runtime: Agent; + skills: string[]; + prompt: string; + archived_at: number | null; + avatar_asset_path: string | null; + avatar_status: string | null; + 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 | null; + outbox_instructions: string | null; + validation_result: unknown; + created_at: number; +} + +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: RouteToken[]; + 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; + output_tokens: number; + cache_read_tokens: number; + cache_write_tokens: number; + tool_call_count: number; + outbox_write_count: number; + error_count: number; + updated_at: number; +} + +export interface AgentTurnTrace { + turn: AgentTurn; + metrics: TurnMetrics; + 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; + 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 } } From 430dd376fbc46fd1c28bb75864e0fc7edc5c67a1 Mon Sep 17 00:00:00 2001 From: Noel Kwan Date: Thu, 30 Apr 2026 13:51:09 +0800 Subject: [PATCH 20/35] fix: align daemon frontend profile and trace types --- src/lib/daemon/client.test.ts | 60 +++++++++++++++++++++++++++++++++++ src/lib/daemon/client.ts | 3 +- src/lib/daemon/types.test.ts | 43 ++++++++++++++++++++++++- src/lib/daemon/types.ts | 23 ++++++++------ 4 files changed, 118 insertions(+), 11 deletions(-) diff --git a/src/lib/daemon/client.test.ts b/src/lib/daemon/client.test.ts index 2214de2..bca580f 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]; @@ -88,6 +89,65 @@ describe("DaemonClient", () => { 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: { diff --git a/src/lib/daemon/client.ts b/src/lib/daemon/client.ts index e022a14..f95d405 100644 --- a/src/lib/daemon/client.ts +++ b/src/lib/daemon/client.ts @@ -11,6 +11,7 @@ import type { ChatWorkspaceLink, DaemonSession, EventRecord, + SavedAgentProfile, SendChatMessageRequest, } from "./types"; @@ -124,7 +125,7 @@ export class DaemonClient { listProfiles(): Promise { return this.call("/profiles"); } - saveProfile(req: SaveProfileRequest): Promise { + saveProfile(req: SaveProfileRequest): Promise { if (req.id) { return this.call(`/profiles/${req.id}`, { method: "PATCH", body: JSON.stringify(req) }); } diff --git a/src/lib/daemon/types.test.ts b/src/lib/daemon/types.test.ts index a6bac73..4b96eea 100644 --- a/src/lib/daemon/types.test.ts +++ b/src/lib/daemon/types.test.ts @@ -1,7 +1,8 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, expectTypeOf } from "vitest"; import fixture from "./__fixtures__/events.json"; import type { AgentProfile, + AgentProfileVersion, AgentTurnTrace, Chat, ChatMetrics, @@ -9,6 +10,7 @@ import type { EventRecord, OutboxEvent, RouteToken, + TurnMetrics, } from "./types"; describe("daemon event types", () => { @@ -123,4 +125,43 @@ describe("daemon event types", () => { 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 11986b5..516c171 100644 --- a/src/lib/daemon/types.ts +++ b/src/lib/daemon/types.ts @@ -35,13 +35,13 @@ export interface AgentProfile { id: string; handle: string; name: string; - description: string | null; + description: string; runtime: Agent; skills: string[]; prompt: string; archived_at: number | null; avatar_asset_path: string | null; - avatar_status: string | null; + avatar_status: string; avatar_error: string | null; active_version_id: string | null; created_at: number; @@ -55,12 +55,17 @@ export interface AgentProfileVersion { model: string | null; prompt: string; skills: string[]; - default_workspace_behavior: string | null; - outbox_instructions: string | null; + 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; @@ -131,10 +136,10 @@ export interface AgentTurn { export interface TurnMetrics { turn_id: string; - input_tokens: number; - output_tokens: number; - cache_read_tokens: number; - cache_write_tokens: number; + 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; @@ -143,7 +148,7 @@ export interface TurnMetrics { export interface AgentTurnTrace { turn: AgentTurn; - metrics: TurnMetrics; + metrics: TurnMetrics | null; events: EventRecord[]; } From 1d044183f3f327ddc450e2012f07d3812892b3e0 Mon Sep 17 00:00:00 2001 From: Noel Kwan Date: Thu, 30 Apr 2026 14:02:24 +0800 Subject: [PATCH 21/35] fix: preserve daemon chat token span payloads --- src/lib/daemon/types.test.ts | 19 ++++++++++++++++++- src/lib/daemon/types.ts | 2 +- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/lib/daemon/types.test.ts b/src/lib/daemon/types.test.ts index 4b96eea..92a1ee4 100644 --- a/src/lib/daemon/types.test.ts +++ b/src/lib/daemon/types.test.ts @@ -42,7 +42,24 @@ describe("daemon event types", () => { created_at: 10, }; - expect(message.token_spans[0]).toEqual({ kind: "shadow", handle: "critic", start: 0, end: 7 }); + 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", () => { diff --git a/src/lib/daemon/types.ts b/src/lib/daemon/types.ts index 516c171..e2f9617 100644 --- a/src/lib/daemon/types.ts +++ b/src/lib/daemon/types.ts @@ -87,7 +87,7 @@ export interface ChatMessage { chat_id: string; idempotency_id: string | null; body: string; - token_spans: RouteToken[]; + token_spans: unknown; created_at: number; } From c172458d7daef9b6d5fa6cd25ce255e06b01e7cf Mon Sep 17 00:00:00 2001 From: Noel Kwan Date: Thu, 30 Apr 2026 14:14:07 +0800 Subject: [PATCH 22/35] feat: add agent creation workspace --- src/App.svelte | 32 ++ src/App.test.ts | 19 +- src/lib/HotkeyHelp.svelte | 4 +- src/lib/HotkeyManager.svelte | 8 + src/lib/HotkeyManager.test.ts | 10 +- src/lib/WorkspaceModePicker.svelte | 2 + src/lib/__tests__/WorkspaceModePicker.test.ts | 10 +- src/lib/agents/AgentCreationWorkspace.svelte | 343 ++++++++++++++++ .../AgentCreationWorkspace.test.svelte.ts | 120 ++++++ src/lib/agents/ProfileEditor.svelte | 382 ++++++++++++++++++ src/lib/agents/ProfileList.svelte | 305 ++++++++++++++ src/lib/agents/ProfilePreviewDrawer.svelte | 189 +++++++++ src/lib/agents/profile-validation.test.ts | 29 ++ src/lib/agents/profile-validation.ts | 69 ++++ src/lib/stores.ts | 4 +- 15 files changed, 1520 insertions(+), 6 deletions(-) create mode 100644 src/lib/agents/AgentCreationWorkspace.svelte create mode 100644 src/lib/agents/AgentCreationWorkspace.test.svelte.ts create mode 100644 src/lib/agents/ProfileEditor.svelte create mode 100644 src/lib/agents/ProfileList.svelte create mode 100644 src/lib/agents/ProfilePreviewDrawer.svelte create mode 100644 src/lib/agents/profile-validation.test.ts create mode 100644 src/lib/agents/profile-validation.ts diff --git a/src/App.svelte b/src/App.svelte index 4125b3f..d0c9597 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -12,6 +12,7 @@ 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 KanbanBoard from "./lib/KanbanBoard.svelte"; import ChatWorkspace from "./lib/chat/ChatWorkspace.svelte"; import { refreshProjectsFromBackend } from "./lib/project-listing"; @@ -175,6 +176,15 @@
{#if workspaceModeState.current === "agents"} + {:else if workspaceModeState.current === "agent-create"} + + {:else if workspaceModeState.current === "agent-observe"} +
+
+

Agent Observe

+ Observability workspace is queued for the next agent architecture task. +
+
{:else if workspaceModeState.current === "kanban"} {:else} @@ -230,4 +240,26 @@ flex: 1; overflow: hidden; } + .workspace-placeholder { + height: 100%; + display: grid; + place-items: center; + background: var(--bg-void); + color: var(--text-primary); + } + .workspace-placeholder div { + display: grid; + gap: 8px; + border: 1px solid var(--border-default); + border-radius: 8px; + background: var(--bg-base); + padding: 24px; + } + .workspace-placeholder p { + font: 600 16px var(--font-mono); + } + .workspace-placeholder span { + color: var(--text-secondary); + font-size: 13px; + } diff --git a/src/App.test.ts b/src/App.test.ts index adb015a..2f4f91d 100644 --- a/src/App.test.ts +++ b/src/App.test.ts @@ -26,6 +26,7 @@ const componentMocks = vi.hoisted(() => ({ keystrokeVisualizer: vi.fn(), workspaceModePicker: vi.fn(), agentDashboard: vi.fn(), + agentCreationWorkspace: vi.fn(), kanbanBoard: vi.fn(), chatWorkspace: vi.fn(), })); @@ -38,6 +39,7 @@ 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/KanbanBoard.svelte", () => ({ default: componentMocks.kanbanBoard })); vi.mock("./lib/chat/ChatWorkspace.svelte", () => ({ default: componentMocks.chatWorkspace })); vi.mock("./lib/toast", () => ({ @@ -106,13 +108,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, observe placeholder, 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(screen.getAllByText("Agent Observe").length).toBeGreaterThan(0); + }); + 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..eb7ebd1 100644 --- a/src/lib/HotkeyManager.svelte +++ b/src/lib/HotkeyManager.svelte @@ -82,6 +82,14 @@ } if (key === "c") { switchWorkspaceMode("chat"); + return; + } + if (key === "p") { + switchWorkspaceMode("agent-create"); + return; + } + if (key === "o") { + switchWorkspaceMode("agent-observe"); } } 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/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..f21a6ed --- /dev/null +++ b/src/lib/agents/AgentCreationWorkspace.svelte @@ -0,0 +1,343 @@ + + +
+ + +
+ {#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..71167aa --- /dev/null +++ b/src/lib/agents/AgentCreationWorkspace.test.svelte.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } 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("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); + }); + }); +}); 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..7c359dc --- /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/stores.ts b/src/lib/stores.ts index dbb113e..871e5c4 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); From 6d6bd68db1e533916b08ea99b9eb1f2336ebe3e0 Mon Sep 17 00:00:00 2001 From: Noel Kwan Date: Thu, 30 Apr 2026 14:20:35 +0800 Subject: [PATCH 23/35] fix: filter archived agent profiles --- .../AgentCreationWorkspace.test.svelte.ts | 27 ++++++++++++++++++- src/lib/agents/ProfileList.svelte | 2 +- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/lib/agents/AgentCreationWorkspace.test.svelte.ts b/src/lib/agents/AgentCreationWorkspace.test.svelte.ts index 71167aa..5670c8c 100644 --- a/src/lib/agents/AgentCreationWorkspace.test.svelte.ts +++ b/src/lib/agents/AgentCreationWorkspace.test.svelte.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { fireEvent, render, screen, waitFor } from "@testing-library/svelte"; +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"; @@ -83,6 +83,31 @@ describe("AgentCreationWorkspace", () => { 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" })); diff --git a/src/lib/agents/ProfileList.svelte b/src/lib/agents/ProfileList.svelte index 7c359dc..1be98c4 100644 --- a/src/lib/agents/ProfileList.svelte +++ b/src/lib/agents/ProfileList.svelte @@ -44,7 +44,7 @@ let filteredProfiles = $derived( profiles.filter((profile) => { - if (!showArchived && profile.archived_at !== null) return false; + if (showArchived !== (profile.archived_at !== null)) return false; const haystack = [ profile.name, profile.handle, From 4a6a5e1aa591caceae8ebab422e352b815e593ec Mon Sep 17 00:00:00 2001 From: Noel Kwan Date: Thu, 30 Apr 2026 14:27:19 +0800 Subject: [PATCH 24/35] fix: preserve agent profile save payloads --- src/lib/agents/AgentCreationWorkspace.svelte | 41 +++++++++++++------ .../AgentCreationWorkspace.test.svelte.ts | 41 +++++++++++++++++++ 2 files changed, 69 insertions(+), 13 deletions(-) diff --git a/src/lib/agents/AgentCreationWorkspace.svelte b/src/lib/agents/AgentCreationWorkspace.svelte index f21a6ed..6084af7 100644 --- a/src/lib/agents/AgentCreationWorkspace.svelte +++ b/src/lib/agents/AgentCreationWorkspace.svelte @@ -1,6 +1,7 @@ @@ -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/ChatView.svelte b/src/lib/chat/ChatView.svelte index 04372c8..bf735e9 100644 --- a/src/lib/chat/ChatView.svelte +++ b/src/lib/chat/ChatView.svelte @@ -1,21 +1,70 @@ -{#if session} +{#if chat && chatId} +
+
+ {chat.title} + chat +
+ + +
+{: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/ChatWorkspace.test.ts b/src/lib/chat/ChatWorkspace.test.ts index a8587ec..b666021 100644 --- a/src/lib/chat/ChatWorkspace.test.ts +++ b/src/lib/chat/ChatWorkspace.test.ts @@ -1,24 +1,59 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, fireEvent } from "@testing-library/svelte"; - -vi.mock("../daemon/store.svelte", () => { - const daemonStore = { - 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 })); +} 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"); @@ -38,4 +73,34 @@ 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(); + }); }); diff --git a/src/lib/chat/__mocks__/daemonStore.svelte.ts b/src/lib/chat/__mocks__/daemonStore.svelte.ts index 1bae3cf..6c66ecc 100644 --- a/src/lib/chat/__mocks__/daemonStore.svelte.ts +++ b/src/lib/chat/__mocks__/daemonStore.svelte.ts @@ -2,12 +2,31 @@ 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 loadChats = 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", + chats: new SvelteMap(), + chatTranscripts: 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..a8ce88f --- /dev/null +++ b/src/lib/chat/chat-routing.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { + extractRouteTokenQuery, + insertRouteToken, + 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 }); + }); +}); diff --git a/src/lib/chat/chat-routing.ts b/src/lib/chat/chat-routing.ts new file mode 100644 index 0000000..5319b31 --- /dev/null +++ b/src/lib/chat/chat-routing.ts @@ -0,0 +1,57 @@ +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_-]*)$/; + +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 }, + }; +} 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/stores.ts b/src/lib/stores.ts index 871e5c4..1861bac 100644 --- a/src/lib/stores.ts +++ b/src/lib/stores.ts @@ -204,6 +204,7 @@ export type AgentKind = "auto-worker" | "maintainer"; export type FocusTarget = | { type: "session"; sessionId: string; projectId: string } + | { type: "chat"; chatId: string; projectId: string } | { type: "project"; projectId: string } | { type: "agent"; agentKind: AgentKind; projectId: string } | { type: "agent-panel"; agentKind: AgentKind; projectId: string } From 190213360a9859de7bf5a218e7327ab54a50286b Mon Sep 17 00:00:00 2001 From: Noel Kwan Date: Thu, 30 Apr 2026 14:45:08 +0800 Subject: [PATCH 26/35] fix: harden composer chat creation flow --- src/lib/chat/ChatView.svelte | 64 ++++++++++++++----- src/lib/chat/ChatView.test.svelte.ts | 93 +++++++++++++++++++++++++++- src/lib/chat/ChatWorkspace.svelte | 6 +- src/lib/chat/ChatWorkspace.test.ts | 42 +++++++++++++ 4 files changed, 185 insertions(+), 20 deletions(-) diff --git a/src/lib/chat/ChatView.svelte b/src/lib/chat/ChatView.svelte index bf735e9..0a925d9 100644 --- a/src/lib/chat/ChatView.svelte +++ b/src/lib/chat/ChatView.svelte @@ -16,6 +16,7 @@ const chatTranscript = $derived(chatId ? transcriptFromChatEntries(daemonStore.chatTranscripts.get(chatId) ?? []) : emptyTranscript()); let handle: { close(): void } | null = null; + let chatLoadGeneration = 0; function transcriptFromChatEntries(entries: ChatTranscriptEntry[]): TranscriptState { let transcript = emptyTranscript(); @@ -44,26 +45,57 @@ }; } - onMount(async () => { + function chatTranscriptEntryKey(entry: ChatTranscriptEntry): string { + if (entry.type === "user_message") return `message:${entry.message.id}`; + const event = entry.event; + return `event:${event.chat_id ?? ""}:${event.session_id}:${event.seq}:${event.channel}:${event.kind}`; + } + + function mergeChatTranscriptEntries( + loaded: ChatTranscriptEntry[], + current: ChatTranscriptEntry[], + ): ChatTranscriptEntry[] { + const seen = new Set(); + const merged: ChatTranscriptEntry[] = []; + for (const entry of [...loaded, ...current]) { + const key = chatTranscriptEntryKey(entry); + if (seen.has(key)) continue; + seen.add(key); + merged.push(entry); + } + return merged; + } + + async function loadChatTranscriptFor(nextChatId: string) { if (!daemonStore.client) return; - if (chatId) { - try { - const entries = await daemonStore.client.readChatTranscript(chatId); - daemonStore.chatTranscripts.set(chatId, entries); - } catch (e) { - const c = classifyError(e); - if (c.kind === "not_found") { - daemonStore.chats.delete(chatId); - if (daemonStore.activeChatId === chatId) { - daemonStore.activeChatId = null; - } - showToast("Chat no longer exists.", "error"); - } else { - showToast(`Failed to load chat: ${c.message}`, "error"); + const generation = ++chatLoadGeneration; + try { + const entries = await daemonStore.client.readChatTranscript(nextChatId); + if (generation !== chatLoadGeneration || chatId !== nextChatId) return; + const current = daemonStore.chatTranscripts.get(nextChatId) ?? []; + daemonStore.chatTranscripts.set(nextChatId, mergeChatTranscriptEntries(entries, current)); + } catch (e) { + if (generation !== chatLoadGeneration || chatId !== nextChatId) return; + const c = classifyError(e); + if (c.kind === "not_found") { + daemonStore.chats.delete(nextChatId); + if (daemonStore.activeChatId === nextChatId) { + daemonStore.activeChatId = null; } + showToast("Chat no longer exists.", "error"); + } else { + showToast(`Failed to load chat: ${c.message}`, "error"); } - return; } + } + + $effect(() => { + if (!chatId || !daemonStore.client) return; + void loadChatTranscriptFor(chatId); + }); + + onMount(async () => { + if (!daemonStore.client) return; if (!sessionId) return; try { const events = await daemonStore.client.readEvents(sessionId, 0); diff --git a/src/lib/chat/ChatView.test.svelte.ts b/src/lib/chat/ChatView.test.svelte.ts index edb5bc0..701398a 100644 --- a/src/lib/chat/ChatView.test.svelte.ts +++ b/src/lib/chat/ChatView.test.svelte.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, waitFor } from "@testing-library/svelte"; +import { render, fireEvent, waitFor } from "@testing-library/svelte"; const openStreamMock = vi.hoisted(() => vi.fn(() => ({ close: vi.fn() }))); @@ -7,10 +7,63 @@ 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, 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 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.activeChatId = null; + 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 +79,38 @@ 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", "optimistic hello"), + turns: [], + }); + daemonStore.chats.set("chat-1", makeChat("chat-1", "Chat 1")); + + const { findByText, getByRole } = render(ChatView, { chatId: "chat-1" }); + const ta = getByRole("textbox") as HTMLTextAreaElement; + + await fireEvent.input(ta, { target: { value: "optimistic hello" } }); + await fireEvent.keyDown(ta, { key: "Enter", metaKey: true }); + expect(await findByText("optimistic hello")).toBeTruthy(); + + initialRead.resolve([]); + + await waitFor(() => expect(readChatTranscript).toHaveBeenCalledWith("chat-1")); + expect(await findByText("optimistic hello")).toBeTruthy(); + }); }); diff --git a/src/lib/chat/ChatWorkspace.svelte b/src/lib/chat/ChatWorkspace.svelte index 8d3b0e7..7aa642d 100644 --- a/src/lib/chat/ChatWorkspace.svelte +++ b/src/lib/chat/ChatWorkspace.svelte @@ -26,7 +26,11 @@ } async function createComposerChat(target: { projectId: string; projectCwd: string }) { - if (!daemonStore.client || creatingProjectId) return; + if (creatingProjectId) { + daemonStore.newChatTarget = null; + return; + } + if (!daemonStore.client) return; creatingProjectId = target.projectId; createError = null; daemonStore.newChatTarget = null; diff --git a/src/lib/chat/ChatWorkspace.test.ts b/src/lib/chat/ChatWorkspace.test.ts index b666021..ee7bb1d 100644 --- a/src/lib/chat/ChatWorkspace.test.ts +++ b/src/lib/chat/ChatWorkspace.test.ts @@ -27,6 +27,16 @@ 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(async () => { vi.clearAllMocks(); @@ -103,4 +113,36 @@ describe("ChatWorkspace", () => { 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); + }); }); From e729dc17433c98e6bdfc529598fdbce282169cd0 Mon Sep 17 00:00:00 2001 From: Noel Kwan Date: Thu, 30 Apr 2026 14:54:41 +0800 Subject: [PATCH 27/35] feat: add chat agent routing controls --- src/lib/chat/AgentTokenMenu.svelte | 151 +++++++++++++++++++ src/lib/chat/AgentTokenMenu.test.svelte.ts | 88 +++++++++++ src/lib/chat/ChatInput.svelte | 81 +++++++++- src/lib/chat/ChatInput.test.ts | 48 +++++- src/lib/chat/ChatSummaryPane.svelte | 99 ++++++++++++ src/lib/chat/ChatSummaryPane.test.svelte.ts | 33 ++++ src/lib/chat/ChatView.svelte | 2 + src/lib/chat/ChatView.test.svelte.ts | 34 ++++- src/lib/chat/ChatWorkspace.svelte | 4 +- src/lib/chat/__mocks__/daemonStore.svelte.ts | 2 + 10 files changed, 529 insertions(+), 13 deletions(-) create mode 100644 src/lib/chat/AgentTokenMenu.svelte create mode 100644 src/lib/chat/AgentTokenMenu.test.svelte.ts create mode 100644 src/lib/chat/ChatSummaryPane.svelte create mode 100644 src/lib/chat/ChatSummaryPane.test.svelte.ts diff --git a/src/lib/chat/AgentTokenMenu.svelte b/src/lib/chat/AgentTokenMenu.svelte new file mode 100644 index 0000000..fb28e1b --- /dev/null +++ b/src/lib/chat/AgentTokenMenu.svelte @@ -0,0 +1,151 @@ + + + + + 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 50c8dca..da7168c 100644 --- a/src/lib/chat/ChatInput.svelte +++ b/src/lib/chat/ChatInput.svelte @@ -1,9 +1,17 @@ + +
+ {#if isEmpty} + none + {:else} +
+ agents + {#each agents as agent (agent.id)} + @{agent.handle} + {/each} + {#if focusedAgent} + focused @{focusedAgent.handle} + {/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 0a925d9..a271c86 100644 --- a/src/lib/chat/ChatView.svelte +++ b/src/lib/chat/ChatView.svelte @@ -8,6 +8,7 @@ import type { ChatTranscriptEntry, EventRecord } from "../daemon/types"; import Transcript from "./Transcript.svelte"; import ChatInput from "./ChatInput.svelte"; + import ChatSummaryPane from "./ChatSummaryPane.svelte"; let { sessionId = null, chatId = null }: { sessionId?: string | null; chatId?: string | null } = $props(); const session = $derived(sessionId ? daemonStore.sessions.get(sessionId) : null); @@ -126,6 +127,7 @@ {chat.title} chat + diff --git a/src/lib/chat/ChatView.test.svelte.ts b/src/lib/chat/ChatView.test.svelte.ts index 701398a..73fa008 100644 --- a/src/lib/chat/ChatView.test.svelte.ts +++ b/src/lib/chat/ChatView.test.svelte.ts @@ -31,6 +31,25 @@ function makeChatMessage(id: string, chatId: string, body: string) { }; } +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 deferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; @@ -57,6 +76,8 @@ describe("ChatView", () => { daemonStore.activeSessionId = "s1"; daemonStore.chats.clear(); daemonStore.chatTranscripts.clear(); + daemonStore.profiles.clear(); + daemonStore.profiles.set("profile-1", makeProfile()); daemonStore.activeChatId = null; readChatTranscript.mockResolvedValue([]); sendChatMessage.mockResolvedValue({ @@ -96,21 +117,24 @@ describe("ChatView", () => { const initialRead = deferred(); readChatTranscript.mockReturnValueOnce(initialRead.promise); sendChatMessage.mockResolvedValueOnce({ - message: makeChatMessage("msg-optimistic", "chat-1", "optimistic hello"), + message: makeChatMessage("msg-optimistic", "chat-1", "ask %reviewer optimistic hello"), turns: [], }); daemonStore.chats.set("chat-1", makeChat("chat-1", "Chat 1")); - const { findByText, getByRole } = render(ChatView, { chatId: "chat-1" }); + const { findByRole, findByText, getByRole } = render(ChatView, { chatId: "chat-1" }); const ta = getByRole("textbox") as HTMLTextAreaElement; - await fireEvent.input(ta, { target: { value: "optimistic hello" } }); + 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("optimistic hello")).toBeTruthy(); + expect(await findByText("ask %reviewer optimistic hello")).toBeTruthy(); initialRead.resolve([]); await waitFor(() => expect(readChatTranscript).toHaveBeenCalledWith("chat-1")); - expect(await findByText("optimistic hello")).toBeTruthy(); + expect(await findByText("ask %reviewer optimistic hello")).toBeTruthy(); }); }); diff --git a/src/lib/chat/ChatWorkspace.svelte b/src/lib/chat/ChatWorkspace.svelte index 7aa642d..f9791bd 100644 --- a/src/lib/chat/ChatWorkspace.svelte +++ b/src/lib/chat/ChatWorkspace.svelte @@ -1,6 +1,6 @@
@@ -33,10 +38,10 @@
agents {#each agents as agent (agent.id)} - @{agent.handle} + {agentLabel(agent)} {/each} {#if focusedAgent} - focused @{focusedAgent.handle} + focused {agentLabel(focusedAgent)} {/if}
diff --git a/src/lib/chat/ChatView.svelte b/src/lib/chat/ChatView.svelte index a271c86..72e5751 100644 --- a/src/lib/chat/ChatView.svelte +++ b/src/lib/chat/ChatView.svelte @@ -5,16 +5,20 @@ import { openStream } from "../daemon/stream"; import { classifyError } from "../daemon/errors"; import { showToast } from "$lib/toast"; - import type { ChatTranscriptEntry, EventRecord } from "../daemon/types"; + import type { ChatTranscriptEntry, EventRecord, RouteToken, RouteTokenKind } from "../daemon/types"; import Transcript from "./Transcript.svelte"; import ChatInput from "./ChatInput.svelte"; - import ChatSummaryPane from "./ChatSummaryPane.svelte"; + import ChatSummaryPane, { type SummaryAgent, type SummaryWorkspace } from "./ChatSummaryPane.svelte"; let { sessionId = null, chatId = null }: { sessionId?: string | null; chatId?: string | null } = $props(); const session = $derived(sessionId ? daemonStore.sessions.get(sessionId) : null); const chat = $derived(chatId ? daemonStore.chats.get(chatId) : null); const sessionTranscript = $derived(sessionId ? daemonStore.transcripts.get(sessionId) ?? emptyTranscript() : emptyTranscript()); - const chatTranscript = $derived(chatId ? transcriptFromChatEntries(daemonStore.chatTranscripts.get(chatId) ?? []) : emptyTranscript()); + const chatEntries = $derived(chatId ? daemonStore.chatTranscripts.get(chatId) ?? [] : []); + const chatTranscript = $derived(chatId ? transcriptFromChatEntries(chatEntries) : emptyTranscript()); + const summaryAgents = $derived(chatId ? summaryAgentsFromChat(chatEntries) : []); + const summaryWorkspaces = $derived(chatId ? summaryWorkspacesFromChat(chatId) : []); + const hasAssociatedAgent = $derived(summaryAgents.length > 0 || (chatId ? (daemonStore.chatAgentLinks.get(chatId)?.length ?? 0) > 0 : false)); let handle: { close(): void } | null = null; let chatLoadGeneration = 0; @@ -46,6 +50,51 @@ }; } + function isRouteTokenSpan(span: unknown): span is Pick { + if (!span || typeof span !== "object") return false; + const candidate = span as { kind?: unknown; handle?: unknown }; + return ( + (candidate.kind === "reusable" || candidate.kind === "shadow") && + typeof candidate.handle === "string" + ); + } + + function tokenSpansFromEntry(entry: ChatTranscriptEntry): Pick[] { + if (entry.type !== "user_message") return []; + if (!Array.isArray(entry.message.token_spans)) return []; + return entry.message.token_spans.filter(isRouteTokenSpan); + } + + function summaryAgentsFromChat(entries: ChatTranscriptEntry[]): SummaryAgent[] { + const seen = new Set(); + const agents: SummaryAgent[] = []; + for (const entry of entries) { + for (const span of tokenSpansFromEntry(entry)) { + const key = `${span.kind}:${span.handle}`; + if (seen.has(key)) continue; + seen.add(key); + const profile = [...daemonStore.profiles.values()].find((candidate) => candidate.handle === span.handle); + agents.push({ + id: profile ? `${profile.id}:${span.kind}` : key, + handle: span.handle, + name: profile?.name ?? span.handle, + kind: span.kind as RouteTokenKind, + focused: span.kind === "reusable" && !agents.some((agent) => agent.focused), + }); + } + } + return agents; + } + + function summaryWorkspacesFromChat(id: string): SummaryWorkspace[] { + return (daemonStore.chatWorkspaceLinks.get(id) ?? []).map((link) => ({ + id: link.id, + label: link.label, + path: link.path, + focused: link.focused, + })); + } + function chatTranscriptEntryKey(entry: ChatTranscriptEntry): string { if (entry.type === "user_message") return `message:${entry.message.id}`; const event = entry.event; @@ -127,9 +176,9 @@ {chat.title} chat - + - + {:else if session && sessionId}
diff --git a/src/lib/chat/ChatView.test.svelte.ts b/src/lib/chat/ChatView.test.svelte.ts index 73fa008..fa6b14c 100644 --- a/src/lib/chat/ChatView.test.svelte.ts +++ b/src/lib/chat/ChatView.test.svelte.ts @@ -50,6 +50,21 @@ function makeProfile() { }; } +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; @@ -76,6 +91,8 @@ describe("ChatView", () => { daemonStore.activeSessionId = "s1"; daemonStore.chats.clear(); daemonStore.chatTranscripts.clear(); + daemonStore.chatAgentLinks.clear(); + daemonStore.chatWorkspaceLinks.clear(); daemonStore.profiles.clear(); daemonStore.profiles.set("profile-1", makeProfile()); daemonStore.activeChatId = null; @@ -137,4 +154,35 @@ describe("ChatView", () => { 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(); + }); }); diff --git a/src/lib/chat/__mocks__/daemonStore.svelte.ts b/src/lib/chat/__mocks__/daemonStore.svelte.ts index 473eda1..0932c88 100644 --- a/src/lib/chat/__mocks__/daemonStore.svelte.ts +++ b/src/lib/chat/__mocks__/daemonStore.svelte.ts @@ -28,6 +28,8 @@ export const daemonStore = $state({ profiles: new SvelteMap(), chats: new SvelteMap(), chatTranscripts: new SvelteMap(), + chatAgentLinks: new SvelteMap(), + chatWorkspaceLinks: new SvelteMap(), activeChatId: null, reachable: true, newChatTarget: null, diff --git a/src/lib/daemon/store.svelte.ts b/src/lib/daemon/store.svelte.ts index e977134..1503a14 100644 --- a/src/lib/daemon/store.svelte.ts +++ b/src/lib/daemon/store.svelte.ts @@ -1,6 +1,15 @@ import { SvelteMap } from "svelte/reactivity"; import { DaemonClient } from "./client"; -import type { AgentProfile, AgentTurnTrace, Chat, ChatMetrics, ChatTranscriptEntry, DaemonSession } from "./types"; +import type { + AgentProfile, + AgentTurnTrace, + Chat, + ChatAgentLink, + ChatMetrics, + ChatTranscriptEntry, + ChatWorkspaceLink, + DaemonSession, +} from "./types"; import { emptyTranscript, type TranscriptState } from "./reducer"; const BASE_URL = "/api/daemon"; @@ -15,6 +24,8 @@ interface StoreState { chats: SvelteMap; activeChatId: string | null; chatTranscripts: SvelteMap; + chatAgentLinks: SvelteMap; + chatWorkspaceLinks: SvelteMap; chatSummaries: SvelteMap; agentTraces: SvelteMap; newChatTarget: { projectId: string; projectCwd: string } | null; @@ -30,6 +41,8 @@ export const daemonStore = $state({ chats: new SvelteMap(), activeChatId: null, chatTranscripts: new SvelteMap(), + chatAgentLinks: new SvelteMap(), + chatWorkspaceLinks: new SvelteMap(), chatSummaries: new SvelteMap(), agentTraces: new SvelteMap(), newChatTarget: null, From 97e57a15d77f7827f6f7eeae863797a435ed6e60 Mon Sep 17 00:00:00 2001 From: Noel Kwan Date: Thu, 30 Apr 2026 15:06:32 +0800 Subject: [PATCH 29/35] fix: harden chat routing summaries --- src/lib/chat/ChatInput.svelte | 9 ++-- src/lib/chat/ChatInput.test.ts | 14 +++++++ src/lib/chat/ChatView.svelte | 63 ++++++++++++++++++++-------- src/lib/chat/ChatView.test.svelte.ts | 49 ++++++++++++++++++++++ src/lib/chat/chat-routing.test.ts | 14 +++++++ src/lib/chat/chat-routing.ts | 21 ++++++++++ 6 files changed, 150 insertions(+), 20 deletions(-) diff --git a/src/lib/chat/ChatInput.svelte b/src/lib/chat/ChatInput.svelte index 11ad12d..4a4960a 100644 --- a/src/lib/chat/ChatInput.svelte +++ b/src/lib/chat/ChatInput.svelte @@ -8,6 +8,7 @@ import { extractRouteTokenQuery, insertRouteToken, + reconcileRouteTokens, routeTokenForProfile, type RouteTokenQuery, } from "./chat-routing"; @@ -54,7 +55,8 @@ async function sendText() { const text = value.trim(); if (!text || busy || disabled || !daemonStore.client) return; - if (chatId && routeTokens.length === 0 && !hasAssociatedAgent) { + const tokensToSend = chatId ? reconcileRouteTokens(value, routeTokens) : []; + if (chatId && tokensToSend.length === 0 && !hasAssociatedAgent) { localError = "Select an agent with @ or % before sending."; return; } @@ -63,7 +65,7 @@ if (chatId) { const res = await daemonStore.client.sendChatMessage(chatId, { body: text, - tokens: routeTokens, + tokens: tokensToSend, idempotency_id: crypto.randomUUID(), }); const prev = daemonStore.chatTranscripts.get(chatId) ?? []; @@ -119,6 +121,7 @@ function handleInput(e: Event) { const target = e.currentTarget as HTMLTextAreaElement; value = target.value; + routeTokens = reconcileRouteTokens(target.value, routeTokens); localError = null; updateTokenQuery(target.value, target.selectionStart); } @@ -163,7 +166,7 @@ }); function handleKeyDown(e: KeyboardEvent) { - if (tokenQuery && !e.metaKey && !e.ctrlKey) { + if (tokenQuery && suggestedProfiles.length > 0 && !e.metaKey && !e.ctrlKey) { if (e.key === "ArrowDown") { e.preventDefault(); moveActiveSuggestion(1); diff --git a/src/lib/chat/ChatInput.test.ts b/src/lib/chat/ChatInput.test.ts index 13b87da..1db90e4 100644 --- a/src/lib/chat/ChatInput.test.ts +++ b/src/lib/chat/ChatInput.test.ts @@ -137,6 +137,20 @@ describe("ChatInput", () => { }); }); + it("does not send a stale selected token after the token text is removed", async () => { + const { getByRole, findByRole } = render(ChatInput, { chatId: "chat-1", status: "running", statusState: "idle" }); + const ta = getByRole("textbox") as HTMLTextAreaElement; + + await fireEvent.input(ta, { target: { value: "ask %rev" } }); + await fireEvent.click(await findByRole("option", { name: /%reviewer/i })); + await waitFor(() => expect(ta.value).toBe("ask %reviewer")); + await fireEvent.input(ta, { target: { value: "ask without a token" } }); + await fireEvent.keyDown(ta, { key: "Enter", metaKey: true }); + + expect(sendChatMessage).not.toHaveBeenCalled(); + expect(getByRole("alert").textContent).toContain("agent"); + }); + it("Ctrl+Enter also sends user_text", async () => { const { getByRole } = render(ChatInput, { sessionId: "s1", status: "running", statusState: "idle" }); const ta = getByRole("textbox") as HTMLTextAreaElement; diff --git a/src/lib/chat/ChatView.svelte b/src/lib/chat/ChatView.svelte index 72e5751..00e9586 100644 --- a/src/lib/chat/ChatView.svelte +++ b/src/lib/chat/ChatView.svelte @@ -5,7 +5,7 @@ import { openStream } from "../daemon/stream"; import { classifyError } from "../daemon/errors"; import { showToast } from "$lib/toast"; - import type { ChatTranscriptEntry, EventRecord, RouteToken, RouteTokenKind } from "../daemon/types"; + import type { ChatAgentLink, ChatTranscriptEntry, EventRecord, RouteToken, RouteTokenKind } from "../daemon/types"; import Transcript from "./Transcript.svelte"; import ChatInput from "./ChatInput.svelte"; import ChatSummaryPane, { type SummaryAgent, type SummaryWorkspace } from "./ChatSummaryPane.svelte"; @@ -16,7 +16,7 @@ const sessionTranscript = $derived(sessionId ? daemonStore.transcripts.get(sessionId) ?? emptyTranscript() : emptyTranscript()); const chatEntries = $derived(chatId ? daemonStore.chatTranscripts.get(chatId) ?? [] : []); const chatTranscript = $derived(chatId ? transcriptFromChatEntries(chatEntries) : emptyTranscript()); - const summaryAgents = $derived(chatId ? summaryAgentsFromChat(chatEntries) : []); + const summaryAgents = $derived(chatId ? summaryAgentsFromChat(chatId, chatEntries) : []); const summaryWorkspaces = $derived(chatId ? summaryWorkspacesFromChat(chatId) : []); const hasAssociatedAgent = $derived(summaryAgents.length > 0 || (chatId ? (daemonStore.chatAgentLinks.get(chatId)?.length ?? 0) > 0 : false)); @@ -65,25 +65,54 @@ return entry.message.token_spans.filter(isRouteTokenSpan); } - function summaryAgentsFromChat(entries: ChatTranscriptEntry[]): SummaryAgent[] { - const seen = new Set(); - const agents: SummaryAgent[] = []; + function profileForHandle(handle: string) { + return [...daemonStore.profiles.values()].find((candidate) => candidate.handle === handle) ?? null; + } + + function summaryAgentForToken(span: Pick): SummaryAgent { + const profile = profileForHandle(span.handle); + return { + id: profile ? `${profile.id}:${span.kind}` : `${span.kind}:${span.handle}`, + handle: span.handle, + name: profile?.name ?? span.handle, + kind: span.kind as RouteTokenKind, + }; + } + + function summaryAgentForLink(link: ChatAgentLink): SummaryAgent | null { + const kind: RouteTokenKind = link.route_type === "shadow" ? "shadow" : "reusable"; + const profile = daemonStore.profiles.get(link.profile_id); + const tokenHandle = link.token_source?.match(/^[@%]([A-Za-z0-9_-]+)$/)?.[1] ?? null; + const handle = profile?.handle ?? tokenHandle; + if (!handle) return null; + return { + id: profile ? `${profile.id}:${kind}` : `${kind}:${handle}`, + handle, + name: profile?.name ?? handle, + kind, + }; + } + + function summaryAgentsFromChat(id: string, entries: ChatTranscriptEntry[]): SummaryAgent[] { + const agentsById = new Map(); + let focusedAgentId: string | null = null; for (const entry of entries) { for (const span of tokenSpansFromEntry(entry)) { - const key = `${span.kind}:${span.handle}`; - if (seen.has(key)) continue; - seen.add(key); - const profile = [...daemonStore.profiles.values()].find((candidate) => candidate.handle === span.handle); - agents.push({ - id: profile ? `${profile.id}:${span.kind}` : key, - handle: span.handle, - name: profile?.name ?? span.handle, - kind: span.kind as RouteTokenKind, - focused: span.kind === "reusable" && !agents.some((agent) => agent.focused), - }); + const agent = summaryAgentForToken(span); + if (!agentsById.has(agent.id)) agentsById.set(agent.id, agent); + if (span.kind === "reusable") focusedAgentId = agent.id; } } - return agents; + for (const link of daemonStore.chatAgentLinks.get(id) ?? []) { + const agent = summaryAgentForLink(link); + if (!agent) continue; + if (!agentsById.has(agent.id)) agentsById.set(agent.id, agent); + if (link.focused) focusedAgentId = agent.id; + } + return [...agentsById.values()].map((agent) => ({ + ...agent, + focused: agent.id === focusedAgentId, + })); } function summaryWorkspacesFromChat(id: string): SummaryWorkspace[] { diff --git a/src/lib/chat/ChatView.test.svelte.ts b/src/lib/chat/ChatView.test.svelte.ts index fa6b14c..92ca61a 100644 --- a/src/lib/chat/ChatView.test.svelte.ts +++ b/src/lib/chat/ChatView.test.svelte.ts @@ -185,4 +185,53 @@ describe("ChatView", () => { 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(); + }); }); diff --git a/src/lib/chat/chat-routing.test.ts b/src/lib/chat/chat-routing.test.ts index a8ce88f..34aa0b8 100644 --- a/src/lib/chat/chat-routing.test.ts +++ b/src/lib/chat/chat-routing.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { extractRouteTokenQuery, insertRouteToken, + reconcileRouteTokens, routeTokenForProfile, } from "./chat-routing"; @@ -46,3 +47,16 @@ describe("routeTokenForProfile", () => { ).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 }, + ]); + }); +}); diff --git a/src/lib/chat/chat-routing.ts b/src/lib/chat/chat-routing.ts index 5319b31..3fdd6e9 100644 --- a/src/lib/chat/chat-routing.ts +++ b/src/lib/chat/chat-routing.ts @@ -55,3 +55,24 @@ export function insertRouteToken( 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 = text.slice(token.start, token.end) === tokenText ? token.start : -1; + if (start === -1) { + start = text.indexOf(tokenText, Math.max(0, Math.min(token.start, text.length))); + } + if (start === -1) { + start = text.indexOf(tokenText); + } + while (start !== -1 && usedStarts.has(start)) { + start = text.indexOf(tokenText, start + tokenText.length); + } + if (start === -1) return []; + usedStarts.add(start); + return [{ ...token, start, end: start + tokenText.length }]; + }); +} From 86c4e99205bd45f976d6ac5ae9f68a29ff1a5762 Mon Sep 17 00:00:00 2001 From: Noel Kwan Date: Thu, 30 Apr 2026 15:15:43 +0800 Subject: [PATCH 30/35] fix: hydrate chat routing links --- src/lib/chat/ChatInput.svelte | 9 +++- src/lib/chat/ChatInput.test.ts | 17 ++++++++ src/lib/chat/ChatView.svelte | 6 ++- src/lib/chat/__mocks__/daemonStore.svelte.ts | 1 + src/lib/chat/chat-routing.test.ts | 8 ++++ src/lib/chat/chat-routing.ts | 25 +++++++---- src/lib/daemon/client.test.ts | 44 ++++++++++++++++++++ src/lib/daemon/client.ts | 7 ++++ src/lib/daemon/store.svelte.ts | 10 +++++ src/lib/daemon/store.test.ts | 15 ++++++- 10 files changed, 132 insertions(+), 10 deletions(-) diff --git a/src/lib/chat/ChatInput.svelte b/src/lib/chat/ChatInput.svelte index 4a4960a..ed69f44 100644 --- a/src/lib/chat/ChatInput.svelte +++ b/src/lib/chat/ChatInput.svelte @@ -55,7 +55,14 @@ async function sendText() { const text = value.trim(); if (!text || busy || disabled || !daemonStore.client) return; - const tokensToSend = chatId ? reconcileRouteTokens(value, routeTokens) : []; + const trimStartOffset = value.length - value.trimStart().length; + const trimEnd = trimStartOffset + text.length; + const tokensToSend = chatId + ? reconcileRouteTokens(value, routeTokens).flatMap((token) => { + if (token.start < trimStartOffset || token.end > trimEnd) return []; + return [{ ...token, start: token.start - trimStartOffset, end: token.end - trimStartOffset }]; + }) + : []; if (chatId && tokensToSend.length === 0 && !hasAssociatedAgent) { localError = "Select an agent with @ or % before sending."; return; diff --git a/src/lib/chat/ChatInput.test.ts b/src/lib/chat/ChatInput.test.ts index 1db90e4..79d3431 100644 --- a/src/lib/chat/ChatInput.test.ts +++ b/src/lib/chat/ChatInput.test.ts @@ -151,6 +151,23 @@ describe("ChatInput", () => { expect(getByRole("alert").textContent).toContain("agent"); }); + it("aligns token offsets with the trimmed chat body", async () => { + const { getByRole, findByRole } = render(ChatInput, { chatId: "chat-1", status: "running", statusState: "idle" }); + const ta = getByRole("textbox") as HTMLTextAreaElement; + + ta.value = " ask @rev"; + ta.setSelectionRange(10, 10); + await fireEvent.input(ta); + await fireEvent.click(await findByRole("option", { name: /@reviewer/i })); + await fireEvent.keyDown(ta, { key: "Enter", metaKey: true }); + + expect(sendChatMessage).toHaveBeenCalledWith("chat-1", { + body: "ask @reviewer", + tokens: [{ kind: "reusable", handle: "reviewer", start: 4, end: 13 }], + idempotency_id: expect.any(String), + }); + }); + it("Ctrl+Enter also sends user_text", async () => { const { getByRole } = render(ChatInput, { sessionId: "s1", status: "running", statusState: "idle" }); const ta = getByRole("textbox") as HTMLTextAreaElement; diff --git a/src/lib/chat/ChatView.svelte b/src/lib/chat/ChatView.svelte index 00e9586..0901d04 100644 --- a/src/lib/chat/ChatView.svelte +++ b/src/lib/chat/ChatView.svelte @@ -1,6 +1,6 @@ + +
+
+
+

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")} +
+
+ +
+
+ Agent + Status + Turns + Tokens + Tools + Errors +
+ {#each agentRows() as row (row.link.id)} + + {:else} +

No linked agents yet.

+ {/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..cbb9586 --- /dev/null +++ b/src/lib/chat/ChatMetricsTab.test.svelte.ts @@ -0,0 +1,148 @@ +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, + updated_at: 10, +}; + +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("Reviewer")).toBeTruthy(); + expect(getByText("@reviewer")).toBeTruthy(); + expect(getByText("running")).toBeTruthy(); + expect(getByText("Debugger")).toBeTruthy(); + expect(getByText("%debugger")).toBeTruthy(); + expect(getAllByText("unavailable").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"); + }); +}); diff --git a/src/lib/chat/ChatView.svelte b/src/lib/chat/ChatView.svelte index 0901d04..370cfc9 100644 --- a/src/lib/chat/ChatView.svelte +++ b/src/lib/chat/ChatView.svelte @@ -1,16 +1,19 @@ + + diff --git a/src/lib/observability/AgentTraceView.svelte b/src/lib/observability/AgentTraceView.svelte new file mode 100644 index 0000000..579ced7 --- /dev/null +++ b/src/lib/observability/AgentTraceView.svelte @@ -0,0 +1,396 @@ + + +
+ {#if session} +
+
+

Agent Trace

+

{session.label}

+
+
+ {session.agent} + {session.status} +
+
+ + {#if error} + + {/if} + +
+
+ Current turn + {latestTrace?.turn.id ?? "none"} +
+
+ Turns + {formatMetricValue(traces.length, "turns")} +
+
+ Runtime + {session.native_session_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")} + +
+
+ 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")} +
+
+ {#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..8ee8a10 --- /dev/null +++ b/src/lib/observability/AgentTraceView.test.svelte.ts @@ -0,0 +1,166 @@ +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", + 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(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[1].textContent).toContain("unavailable"); + + 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..c0ee3b6 --- /dev/null +++ b/src/lib/observability/turn-format.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { formatDuration, 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"); + }); +}); + +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..7e5dfcd --- /dev/null +++ b/src/lib/observability/turn-format.ts @@ -0,0 +1,79 @@ +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"; + const ms = Math.max(0, end - start); + 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 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 1861bac..821702b 100644 --- a/src/lib/stores.ts +++ b/src/lib/stores.ts @@ -205,6 +205,7 @@ 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 } From 1aa322f777a886f2c0e8e256986cc95a568a35d0 Mon Sep 17 00:00:00 2001 From: Noel Kwan Date: Thu, 30 Apr 2026 16:10:55 +0800 Subject: [PATCH 33/35] fix: complete observability detail views --- src/lib/chat/ChatMetricsTab.svelte | 230 ++++++++++++++++-- src/lib/chat/ChatMetricsTab.test.svelte.ts | 107 +++++++- src/lib/daemon/types.ts | 57 +++++ src/lib/observability/AgentTraceView.svelte | 56 ++++- .../AgentTraceView.test.svelte.ts | 16 ++ src/lib/observability/turn-format.test.ts | 8 +- src/lib/observability/turn-format.ts | 15 +- 7 files changed, 458 insertions(+), 31 deletions(-) diff --git a/src/lib/chat/ChatMetricsTab.svelte b/src/lib/chat/ChatMetricsTab.svelte index e81c887..f44b48d 100644 --- a/src/lib/chat/ChatMetricsTab.svelte +++ b/src/lib/chat/ChatMetricsTab.svelte @@ -1,6 +1,20 @@
@@ -95,20 +208,21 @@
-
+
Agent Status Turns Tokens Tools + Outbox Errors
- {#each agentRows() as row (row.link.id)} + {#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} +