summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-04 21:21:20 +0900
committerAdam Malczewski <[email protected]>2026-06-04 21:21:20 +0900
commit394f1ed37ce860da6fdc385769bf29f9737105cd (patch)
tree4b825dc642cb6eb9a060e54bf8d69288fbee4904 /packages
parent81a9cdbadf8c9d940d4fe9a2a0de607dee1f5f1a (diff)
downloaddispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.tar.gz
dispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.zip
chore: genesis — remove all files to rebuild from scratch (arch rewrite)
Diffstat (limited to 'packages')
-rw-r--r--packages/api/package.json20
-rw-r--r--packages/api/src/agent-manager.ts2453
-rw-r--r--packages/api/src/app.ts278
-rw-r--r--packages/api/src/index.ts127
-rw-r--r--packages/api/src/permission-manager.ts103
-rw-r--r--packages/api/src/routes/agents.ts126
-rw-r--r--packages/api/src/routes/config.ts27
-rw-r--r--packages/api/src/routes/models.ts1073
-rw-r--r--packages/api/src/routes/notifications.ts88
-rw-r--r--packages/api/src/routes/skills.ts48
-rw-r--r--packages/api/src/routes/tabs.ts229
-rw-r--r--packages/api/src/types.ts2
-rw-r--r--packages/api/src/wake-scheduler.ts97
-rw-r--r--packages/api/tests/agent-manager.test.ts2142
-rw-r--r--packages/api/tests/permission-manager.test.ts99
-rw-r--r--packages/api/tests/routes.test.ts1097
-rw-r--r--packages/api/tests/wake-scheduler.test.ts98
-rw-r--r--packages/api/tsconfig.json10
-rw-r--r--packages/api/vitest.config.ts12
-rw-r--r--packages/core/package.json29
-rw-r--r--packages/core/src/agent/agent.tsbin66313 -> 0 bytes
-rw-r--r--packages/core/src/agents/index.ts11
-rw-r--r--packages/core/src/agents/loader.ts294
-rw-r--r--packages/core/src/chunks/append.ts314
-rw-r--r--packages/core/src/chunks/transform.ts271
-rw-r--r--packages/core/src/compaction/index.ts245
-rw-r--r--packages/core/src/config/index.ts9
-rw-r--r--packages/core/src/config/loader.ts226
-rw-r--r--packages/core/src/config/schema.ts239
-rw-r--r--packages/core/src/config/watcher.ts129
-rw-r--r--packages/core/src/credentials/anthropic-betas.ts24
-rw-r--r--packages/core/src/credentials/api-keys.ts80
-rw-r--r--packages/core/src/credentials/claude.ts694
-rw-r--r--packages/core/src/credentials/copilot.ts64
-rw-r--r--packages/core/src/credentials/google.ts178
-rw-r--r--packages/core/src/credentials/index.ts52
-rw-r--r--packages/core/src/credentials/opencode.ts126
-rw-r--r--packages/core/src/credentials/store.ts185
-rw-r--r--packages/core/src/db/chunks.ts246
-rw-r--r--packages/core/src/db/index.ts177
-rw-r--r--packages/core/src/db/settings.ts22
-rw-r--r--packages/core/src/db/tabs.ts250
-rw-r--r--packages/core/src/index.ts172
-rw-r--r--packages/core/src/llm/anthropic-oauth-transform.ts153
-rw-r--r--packages/core/src/llm/debug-logger.ts448
-rw-r--r--packages/core/src/llm/provider.ts180
-rw-r--r--packages/core/src/lsp/client.ts658
-rw-r--r--packages/core/src/lsp/diagnostic.ts41
-rw-r--r--packages/core/src/lsp/index.ts18
-rw-r--r--packages/core/src/lsp/language.ts72
-rw-r--r--packages/core/src/lsp/manager.ts220
-rw-r--r--packages/core/src/lsp/server.ts68
-rw-r--r--packages/core/src/models/attachments.ts151
-rw-r--r--packages/core/src/models/catalog.ts229
-rw-r--r--packages/core/src/models/index.ts24
-rw-r--r--packages/core/src/models/registry.ts86
-rw-r--r--packages/core/src/notifications/config.ts77
-rw-r--r--packages/core/src/notifications/dispatcher.ts287
-rw-r--r--packages/core/src/notifications/index.ts36
-rw-r--r--packages/core/src/notifications/ntfy.ts149
-rw-r--r--packages/core/src/notifications/types.ts108
-rw-r--r--packages/core/src/permission/evaluate.ts15
-rw-r--r--packages/core/src/permission/index.ts26
-rw-r--r--packages/core/src/permission/service.ts76
-rw-r--r--packages/core/src/permission/wildcard.ts10
-rw-r--r--packages/core/src/skills/index.ts7
-rw-r--r--packages/core/src/skills/loader.ts248
-rw-r--r--packages/core/src/skills/parser.ts60
-rw-r--r--packages/core/src/tools/bash-arity.ts48
-rw-r--r--packages/core/src/tools/key-usage.ts322
-rw-r--r--packages/core/src/tools/list-files.ts40
-rw-r--r--packages/core/src/tools/lsp.ts135
-rw-r--r--packages/core/src/tools/path-utils.ts55
-rw-r--r--packages/core/src/tools/read-file-slice.ts90
-rw-r--r--packages/core/src/tools/read-file.ts129
-rw-r--r--packages/core/src/tools/read-tab.ts95
-rw-r--r--packages/core/src/tools/registry.ts91
-rw-r--r--packages/core/src/tools/retrieve.ts71
-rw-r--r--packages/core/src/tools/run-shell.ts176
-rw-r--r--packages/core/src/tools/search-code.ts362
-rw-r--r--packages/core/src/tools/send-to-tab.ts198
-rw-r--r--packages/core/src/tools/shell-analyze.ts166
-rw-r--r--packages/core/src/tools/summon.ts447
-rw-r--r--packages/core/src/tools/task-list.ts149
-rw-r--r--packages/core/src/tools/truncate.ts142
-rw-r--r--packages/core/src/tools/web-search.ts106
-rw-r--r--packages/core/src/tools/write-file.ts66
-rw-r--r--packages/core/src/tools/youtube-transcribe.ts219
-rw-r--r--packages/core/src/types/index.ts672
-rw-r--r--packages/core/tests/agent/agent.test.ts1791
-rw-r--r--packages/core/tests/agents/loader.test.ts233
-rw-r--r--packages/core/tests/chunks/append.test.ts534
-rw-r--r--packages/core/tests/compaction/compaction.test.ts194
-rw-r--r--packages/core/tests/config/loader.test.ts176
-rw-r--r--packages/core/tests/config/lsp-schema.test.ts110
-rw-r--r--packages/core/tests/config/merge.test.ts223
-rw-r--r--packages/core/tests/config/watcher.test.ts50
-rw-r--r--packages/core/tests/credentials/wake-probe.test.ts73
-rw-r--r--packages/core/tests/db/chunks.db.test.ts336
-rw-r--r--packages/core/tests/db/chunks.test.ts179
-rw-r--r--packages/core/tests/db/rekey-chunks.db.test.ts129
-rw-r--r--packages/core/tests/db/tabs.test.ts418
-rw-r--r--packages/core/tests/fixture/lsp/fake-lsp-server.js195
-rw-r--r--packages/core/tests/llm/anthropic-oauth-transform.test.ts137
-rw-r--r--packages/core/tests/llm/provider.test.ts271
-rw-r--r--packages/core/tests/lsp/client.test.ts146
-rw-r--r--packages/core/tests/lsp/diagnostic.test.ts67
-rw-r--r--packages/core/tests/lsp/luau-lsp.smoke.test.ts63
-rw-r--r--packages/core/tests/lsp/manager.test.ts120
-rw-r--r--packages/core/tests/lsp/server.test.ts41
-rw-r--r--packages/core/tests/models/attachments.test.ts136
-rw-r--r--packages/core/tests/models/catalog.test.ts227
-rw-r--r--packages/core/tests/notifications/config.test.ts158
-rw-r--r--packages/core/tests/notifications/dispatcher.test.ts461
-rw-r--r--packages/core/tests/notifications/ntfy.test.ts204
-rw-r--r--packages/core/tests/permission/evaluate.test.ts68
-rw-r--r--packages/core/tests/permission/service.test.ts95
-rw-r--r--packages/core/tests/permission/wildcard.test.ts50
-rw-r--r--packages/core/tests/tools/bash-arity.test.ts36
-rw-r--r--packages/core/tests/tools/key-usage.test.ts317
-rw-r--r--packages/core/tests/tools/list-files.test.ts94
-rw-r--r--packages/core/tests/tools/lsp-tool.test.ts110
-rw-r--r--packages/core/tests/tools/read-file.test.ts142
-rw-r--r--packages/core/tests/tools/read-tab.test.ts101
-rw-r--r--packages/core/tests/tools/registry.test.ts143
-rw-r--r--packages/core/tests/tools/run-shell.test.ts78
-rw-r--r--packages/core/tests/tools/search-code.test.ts511
-rw-r--r--packages/core/tests/tools/send-to-tab.test.ts185
-rw-r--r--packages/core/tests/tools/summon.test.ts349
-rw-r--r--packages/core/tests/tools/task-list.test.ts158
-rw-r--r--packages/core/tests/tools/write-file.test.ts152
-rw-r--r--packages/core/tests/types/reasoning-effort.test.ts48
-rw-r--r--packages/core/tsconfig.json10
-rw-r--r--packages/core/vitest.config.ts14
-rw-r--r--packages/frontend/electron/main.cjs33
-rw-r--r--packages/frontend/electron/preload.cjs7
-rw-r--r--packages/frontend/index.html12
-rw-r--r--packages/frontend/package.json54
-rw-r--r--packages/frontend/serve.ts68
-rw-r--r--packages/frontend/src/App.svelte347
-rw-r--r--packages/frontend/src/app.css111
-rw-r--r--packages/frontend/src/lib/attachment-tokens.ts234
-rw-r--r--packages/frontend/src/lib/cache-warm-storage.ts77
-rw-r--r--packages/frontend/src/lib/cache-warming.svelte.ts318
-rw-r--r--packages/frontend/src/lib/components/AgentBuilder.svelte806
-rw-r--r--packages/frontend/src/lib/components/CacheRatePanel.svelte124
-rw-r--r--packages/frontend/src/lib/components/ChatInput.svelte396
-rw-r--r--packages/frontend/src/lib/components/ChatMessage.svelte148
-rw-r--r--packages/frontend/src/lib/components/ChatPanel.svelte181
-rw-r--r--packages/frontend/src/lib/components/ClaudeReset.svelte375
-rw-r--r--packages/frontend/src/lib/components/ConfigPanel.svelte251
-rw-r--r--packages/frontend/src/lib/components/ContextWindowPanel.svelte85
-rw-r--r--packages/frontend/src/lib/components/DebugPanel.svelte35
-rw-r--r--packages/frontend/src/lib/components/Header.svelte27
-rw-r--r--packages/frontend/src/lib/components/HotReloadIndicator.svelte35
-rw-r--r--packages/frontend/src/lib/components/KeyUsage.svelte477
-rw-r--r--packages/frontend/src/lib/components/MarkdownRenderer.svelte174
-rw-r--r--packages/frontend/src/lib/components/ModelSelector.svelte636
-rw-r--r--packages/frontend/src/lib/components/ModelStatus.svelte356
-rw-r--r--packages/frontend/src/lib/components/PermissionPrompt.svelte100
-rw-r--r--packages/frontend/src/lib/components/SettingsPanel.svelte643
-rw-r--r--packages/frontend/src/lib/components/SidebarPanel.svelte220
-rw-r--r--packages/frontend/src/lib/components/SkillsBrowser.svelte317
-rw-r--r--packages/frontend/src/lib/components/SystemPromptPanel.svelte61
-rw-r--r--packages/frontend/src/lib/components/TabBar.svelte234
-rw-r--r--packages/frontend/src/lib/components/TaskListPanel.svelte80
-rw-r--r--packages/frontend/src/lib/components/ToolCallDisplay.svelte140
-rw-r--r--packages/frontend/src/lib/components/ToolPermissions.svelte185
-rw-r--r--packages/frontend/src/lib/config.ts44
-rw-r--r--packages/frontend/src/lib/context-window.ts37
-rw-r--r--packages/frontend/src/lib/router.svelte.ts12
-rw-r--r--packages/frontend/src/lib/settings.svelte.ts88
-rw-r--r--packages/frontend/src/lib/sidebar-storage.ts84
-rw-r--r--packages/frontend/src/lib/snapshot-sequencer.ts47
-rw-r--r--packages/frontend/src/lib/tabs.svelte.ts2441
-rw-r--r--packages/frontend/src/lib/theme.ts92
-rw-r--r--packages/frontend/src/lib/types.ts337
-rw-r--r--packages/frontend/src/lib/ws.svelte.ts127
-rw-r--r--packages/frontend/src/main.ts9
-rw-r--r--packages/frontend/src/vite-env.d.ts2
-rw-r--r--packages/frontend/svelte.config.js5
-rw-r--r--packages/frontend/tests/attachment-tokens.test.ts130
-rw-r--r--packages/frontend/tests/cache-warm-storage.test.ts111
-rw-r--r--packages/frontend/tests/cache-warming.test.ts232
-rw-r--r--packages/frontend/tests/chat-store.test.ts2203
-rw-r--r--packages/frontend/tests/context-window.test.ts84
-rw-r--r--packages/frontend/tests/sidebar-storage.test.ts148
-rw-r--r--packages/frontend/tests/snapshot-sequencer.test.ts92
-rw-r--r--packages/frontend/tests/theme.test.ts144
-rw-r--r--packages/frontend/tsconfig.json10
-rw-r--r--packages/frontend/vite.config.ts23
191 files changed, 0 insertions, 42574 deletions
diff --git a/packages/api/package.json b/packages/api/package.json
deleted file mode 100644
index deea9f6..0000000
--- a/packages/api/package.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "name": "@dispatch/api",
- "version": "0.0.1",
- "private": true,
- "type": "module",
- "main": "src/index.ts",
- "scripts": {
- "dev": "bun --watch src/index.ts",
- "test": "vitest run",
- "test:watch": "vitest",
- "typecheck": "tsc --noEmit"
- },
- "dependencies": {
- "hono": "^4.0.0",
- "@dispatch/core": "workspace:*"
- },
- "devDependencies": {
- "@types/bun": "latest"
- }
-}
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts
deleted file mode 100644
index 539663c..0000000
--- a/packages/api/src/agent-manager.ts
+++ /dev/null
@@ -1,2453 +0,0 @@
-import {
- Agent,
- type AgentEvent,
- type AgentModelEntry,
- type AgentSkillMapping,
- type AgentStatus,
- appendChunks,
- appendEventToChunks,
- BackgroundShellStore,
- BackgroundTranscriptStore,
- buildCompactionRequest,
- buildSummaryTurnText,
- type ChatMessage,
- type Chunk,
- type ClaudeAccount,
- clearSpillForTab,
- configToRuleset,
- createConfigWatcher,
- createKeyUsageTool,
- createListFilesTool,
- createLspTool,
- createReadFileSliceTool,
- createReadFileTool,
- createReadTabTool,
- createRetrieveTool,
- createRunShellTool,
- createSearchCodeTool,
- createSendToTabTool,
- createSkillsWatcher,
- createSummonTool,
- createTab,
- createTaskListTool,
- createWebSearchTool,
- createWriteFileTool,
- createYoutubeTranscribeTool,
- type DispatchConfig,
- expandAgentToolNames,
- explodeTurn,
- explodeUserText,
- GLOBAL_AGENTS_DIR,
- getAgentDirPaths,
- getChunksForTab,
- getClaudeAccountsFromDB,
- getMessagesForTab,
- getSetting,
- getTab,
- getUsageStatsForTab,
- groupRowsToMessages,
- LspManager,
- listOpenTabs,
- loadAgent,
- loadAgents,
- loadConfig,
- loadSkills,
- ModelRegistry,
- type QueuedMessage,
- type ReasoningEffort,
- type ResolvedLspServer,
- refreshAccountCredentials,
- refreshAccountCredentialsAsync,
- rekeyChunks,
- reportDiagnostics,
- resolveApiKey,
- resolveServersFromConfig,
- resolveTabPrefix,
- type SkillDefinition,
- type SystemChunkKind,
- shortestUniquePrefix,
- type TabResolution,
- type TabStatusSnapshot,
- TaskList,
- toAvailableSubagents,
- toAvailableUserAgents,
- type UsageData,
- type UsageStats,
- type UserContentPart,
- validateConfig,
- watchDirConfig,
-} from "@dispatch/core";
-import type { PermissionManager } from "./permission-manager.js";
-import { setConfigGetter } from "./routes/config.js";
-import { setAccountsGetter, setModelsGetter } from "./routes/models.js";
-import { setSkillsGetter } from "./routes/skills.js";
-import { setTabsAgentManager } from "./routes/tabs.js";
-
-const TOOL_DESCRIPTIONS: Record<string, string> = {
- read_file: "Read the contents of a file",
- read_file_slice:
- "Read a character-range slice of a single line in a file (for inspecting long lines that read_file truncated)",
- list_files: "List files and directories",
- write_file: "Write content to a file (creates parent directories if needed)",
- run_shell:
- "Execute shell commands in the working directory (bash). Returns stdout, stderr, and exit code. Set background=true to run in the background and get a job_id for later retrieval. Do NOT run destructive or irreversible commands unless the user explicitly requests them.",
- search_code:
- "Search the codebase by query using the 'cs' code search engine (relevance-ranked, structure-aware). Returns the most relevant files first with matching snippets and line numbers. Better than grep/find for exploratory 'where is X / how does Y work' searches; use run_shell with rg for exhaustive exact-match lists.",
- todo: "Create/maintain a todo list to plan and track work. Declarative whole-list write: send the entire list in `todos` each call (it replaces the previous list). Statuses: pending, in_progress, completed, cancelled.",
- key_usage:
- "Report current usage levels for configured API keys: provider, active/exhausted status, remaining rate-limit headroom and reset times per window (5-hour, weekly, monthly where available), and whether the figures are live or cached. Pass key_id for one key; omit to report all. Supported for anthropic and opencode-go keys.",
- summon:
- "Spawn a child agent to work on a task independently. By default blocks until the child finishes. Set background=true to return immediately with an agent_id for later retrieval.",
- retrieve:
- "Wait for a background task to finish and get its result (blocking). Pass the job_id or agent_id.",
- web_search: "Search the web and optionally scrape full page content from results.",
- youtube_transcribe:
- "Fetch the transcript/subtitles for a YouTube video. Set background=true to start in the background and get a job_id for later retrieval.",
- send_to_tab:
- "Send a message to another tab (agent) by its short ID, as shown in the tab bar. Fire-and-forget: it queues/wakes the target and returns immediately without waiting for a reply. Do NOT sleep, poll, or run commands to wait — if the target replies it will wake you with a new message in a later turn; if you are only waiting, end your turn.",
- read_tab:
- "Read another tab (agent)'s most recent completed response by its short ID. Returns a non-blocking snapshot; if the target is still running you get its previous completed turn. Use after send_to_tab to collect a reply.",
- lsp: "Query the configured Language Server (e.g. luau-lsp for Roblox Luau) about a file: diagnostics, hover, definition, references, or documentSymbol. Line/character are 1-based.",
-};
-
-/**
- * Maximum number of CONSECUTIVE agent-to-agent auto-wakes a tab will accept
- * before it stops auto-responding and waits for a human. Each `send_to_tab`
- * that would wake an idle tab consumes one unit; any human-originated message
- * (e.g. via `POST /chat`) refills the budget to full. This bounds runaway
- * agent ping-pong loops (A wakes B wakes A ...) that would otherwise spend
- * tokens unbounded with no human in the loop. See notes/plan-tab-comm.md.
- */
-const MAX_AGENT_AUTO_WAKES = 6;
-
-/**
- * Cap on how many OTHER files' LSP error blocks are appended to a write_file
- * result, after the written file's own errors. Bounds context spend when a
- * single edit surfaces project-wide diagnostics. Mirrors opencode's
- * MAX_PROJECT_DIAGNOSTICS_FILES.
- */
-const MAX_LSP_OTHER_FILE_DIAGNOSTICS = 5;
-
-const DEFAULT_SYSTEM_PROMPT =
- "You are Dispatch, an agent designed to help with any task that the user asks for. Be helpful and concise.";
-
-const TASK_MANAGEMENT_GUIDANCE = `
-## Task Management
-
-You have access to the \`todo\` tool to plan and track tasks. Use it VERY frequently so the user can see your plan and progress in real time. It is also a powerful planning aid: breaking larger work into smaller steps keeps you from forgetting important tasks — that is unacceptable.
-
-The \`todo\` tool is DECLARATIVE: every call sends the ENTIRE list in the \`todos\` parameter and replaces the previous list. There are no ids and no per-item actions — to change one item, resend the whole list with that item updated. To clear the list, send an empty array.
-
-### When to use
-- A task needs 3+ distinct steps, or benefits from planning
-- The user gives multiple tasks (numbered or comma-separated) or asks for a todo list
-- New instructions arrive — capture them as todos
-- You start a task — mark it in_progress (only one at a time) before working
-- You finish a task — mark it completed and add any follow-ups discovered
-
-### When NOT to use
-- A single, straightforward task (or fewer than 3 trivial steps)
-- Purely informational or conversational requests
-- When tracking adds no organizational value
-
-### States
-- pending — not started
-- in_progress — actively working (exactly ONE at a time)
-- completed — finished successfully
-- cancelled — no longer needed
-
-### Rules
-- Send the full desired list every time; the tool replaces the stored list
-- Update status in real time; do NOT batch completions
-- Mark completed only after the work is actually done (including any required verification), never on intent
-- Keep exactly one in_progress while work remains; if blocked, keep it in_progress and add a follow-up todo describing the blocker
-
-### Examples
-
-User: "Run the build and fix any type errors"
-Write the list, then work it: send [{content:"Run the build", status:"in_progress"}, {content:"Fix any type errors", status:"pending"}]. Run the build. If it surfaces 10 errors, resend the whole list — the build item completed, plus one item per error — then drive each to completed one at a time.
-
-User: "How do I print Hello World in Python?"
-No todo needed — this is a single informational question.
-
-User: "Rename getUser to fetchUser across the project"
-Send [{content:"Search for all occurrences of getUser", status:"in_progress"}, ...]. After the grep reveals the files, resend the whole list with one item per file, then work through them, resending the list as each flips to completed.
-`.trim();
-
-/**
- * Returns true for OpenCode Go models served via the Anthropic-format
- * `/messages` endpoint (MiniMax M2.x, Qwen3.x Plus). See
- * https://opencode.ai/docs/go/#endpoints for the per-model endpoint table.
- */
-function isOpencodeGoAnthropicModel(modelId: string): boolean {
- return modelId.startsWith("minimax-") || modelId.startsWith("qwen");
-}
-
-function buildSystemPrompt(toolNames: string[], basePrompt?: string): string {
- const base = basePrompt || DEFAULT_SYSTEM_PROMPT;
- const toolList = toolNames
- .filter((name) => TOOL_DESCRIPTIONS[name])
- .map((name) => `- ${name}: ${TOOL_DESCRIPTIONS[name]}`)
- .join("\n");
-
- if (!toolList) return base;
-
- const hasTodo = toolNames.includes("todo");
- const hasSummon = toolNames.includes("summon");
- let prompt = `${base}\n\nYou have access to the following tools:\n\n${toolList}\n\nWhen asked to work with files, use these tools. Always confirm what you did after completing an action.`;
- if (hasTodo) {
- prompt += `\n\n${TASK_MANAGEMENT_GUIDANCE}`;
- }
- if (hasSummon) {
- prompt +=
- '\n\nYou have pre-configured subagent types. Use summon(agent="slug", task="...") to delegate specialized work to a subagent. Use list_files and read_file to inspect available agent definitions.';
- }
- return prompt;
-}
-
-interface TabAgent {
- agent: Agent | null;
- status: AgentStatus;
- keyId: string | null;
- modelId: string | null;
- taskList: TaskList;
- _lastPermKey?: string;
- /** Ordered key+model fallback hierarchy from the agent definition. */
- agentModels?: AgentModelEntry[];
- /** Abort controller for cancelling a running agent. */
- abortController?: AbortController;
- /** For child agents: resolves when the agent finishes its task. */
- completionResolve?: (
- result: { status: "done"; result: string } | { status: "error"; error: string },
- ) => void;
- completionPromise?: Promise<
- { status: "done"; result: string } | { status: "error"; error: string }
- >;
- /** Accumulated final text output from the child agent. */
- finalOutput?: string;
- /** Tools whitelist for child agents (set by summon). */
- toolsOverride?: string[];
- /** Working directory override for child agents. */
- workingDirectoryOverride?: string;
- /** Queue of messages sent while the agent is running. */
- messageQueue: QueuedMessage[];
- /** Callbacks to wake up blocking tools waiting for queued messages. */
- queueListeners: Array<() => void>;
- /** Store for shell commands backgrounded due to user interrupt. */
- shellStore: BackgroundShellStore;
- /** Store for transcript requests backgrounded due to user interrupt. */
- transcriptStore: BackgroundTranscriptStore;
- /**
- * In-flight assistant chunks for the active turn. `null` when no turn is
- * running. Out-of-band system events (config-reload, cancel, etc.) push
- * onto this list when present; it is exploded into chunk rows when the
- * turn flushes.
- */
- currentChunks: Chunk[] | null;
- /**
- * Opaque id of the in-flight assistant turn, used as the `currentAssistantId`
- * in the WS status snapshot so a reconnecting frontend can align its local
- * streaming message. (No longer a DB row id — the turn is many chunk rows.)
- */
- currentAssistantId: string | null;
- /**
- * `turn_id` shared by the current turn's user message and assistant chunk
- * rows. Set at the start of `processMessage`, cleared when the turn ends.
- */
- currentTurnId: string | null;
- /**
- * Remaining consecutive agent-to-agent auto-wakes this tab will accept
- * before requiring human intervention (see `MAX_AGENT_AUTO_WAKES`).
- * Refilled to the max by any human-originated `deliverMessage`; decremented
- * each time an agent-originated `send_to_tab` wakes this tab from idle. When
- * it hits 0, further agent messages are queued but do NOT start a turn.
- */
- autoWakeBudget: number;
- /**
- * True while this tab is the SOURCE of an in-flight compaction. New
- * messages are queued (not started) until compaction settles so the
- * conversation can't mutate mid-summary.
- */
- compacting?: boolean;
-}
-
-export class AgentManager {
- private tabAgents: Map<string, TabAgent> = new Map();
- private messageCount = 0;
- private eventListeners: Set<(event: AgentEvent & { tabId: string }) => void> = new Set();
- private permissionManager: PermissionManager | undefined;
-
- private config: DispatchConfig;
- private skillsData: { skills: SkillDefinition[]; mappings: AgentSkillMapping[] };
- private modelRegistry: ModelRegistry | null = null;
-
- private configWatcher: { close(): void } | null = null;
- private skillsWatcher: { close(): void } | null = null;
-
- private claudeAccounts: ClaudeAccount[] = [];
-
- /**
- * Process-wide owner of LSP client lifecycles. Servers are declared in the
- * `dispatch.toml` of a tab's effective working directory; clients are
- * spawned lazily per (root + server) and reused across tabs/turns. Shut
- * down in `destroy()`.
- */
- private lspManager: LspManager = new LspManager();
- /**
- * Cache of resolved LSP servers per working directory, so we parse each
- * directory's `dispatch.toml` `[lsp]` block once. Cleared wholesale on any
- * config hot-reload (the watcher fires for the root config; directory-level
- * configs are re-read on demand after a clear).
- */
- private lspServersByDir: Map<string, ResolvedLspServer[]> = new Map();
- /**
- * One file watcher per distinct SUBDIRECTORY config we've cached in
- * `lspServersByDir`. The main `configWatcher` only watches the root +
- * global `dispatch.toml`; a tab whose effective working directory is a
- * subdirectory with its own `dispatch.toml` needs its cache entry cleared
- * when THAT file changes. Keyed by directory; closed on full reload (the
- * cache is dropped wholesale then) and in `destroy()`.
- */
- private lspDirWatchers: Map<string, { close(): void }> = new Map();
- /** Root working directory watched by `configWatcher` (constructor). */
- private rootWorkingDirectory = "";
-
- constructor(permissionManager?: PermissionManager) {
- this.permissionManager = permissionManager;
-
- const workingDirectory = process.env.DISPATCH_WORKING_DIR ?? process.cwd();
- this.rootWorkingDirectory = workingDirectory;
-
- // Load initial config
- this.config = loadConfig(workingDirectory);
- const { errors } = validateConfig(this.config);
- if (errors.length > 0) {
- for (const err of errors) {
- console.warn(`dispatch: config validation warning [${err.path}]: ${err.message}`);
- }
- }
-
- // Initialize model registry + resolver if config has models and keys
- this._initModelRegistry(this.config);
-
- // Load initial skills
- this.skillsData = loadSkills(workingDirectory);
-
- // Discover Claude accounts
- this._refreshClaudeAccounts();
-
- // Wire route getters
- setConfigGetter(() => this.config);
- setSkillsGetter(() => this.skillsData);
- setModelsGetter(() => this.modelRegistry);
- setAccountsGetter(() => this.claudeAccounts);
- setTabsAgentManager(() => this);
-
- // Set up hot-reload watchers
- this.configWatcher = createConfigWatcher(workingDirectory, (newConfig) => {
- this.config = newConfig;
- const { errors: newErrors } = validateConfig(newConfig);
- if (newErrors.length > 0) {
- for (const err of newErrors) {
- console.warn(`dispatch: config validation warning [${err.path}]: ${err.message}`);
- }
- }
- // Update model registry with new config
- this._initModelRegistry(newConfig);
- // LSP server config may have changed — drop the per-directory cache
- // so the next tool build re-reads each working directory's
- // `dispatch.toml` `[lsp]` block.
- this.lspServersByDir.clear();
- // Tear down the per-subdirectory LSP watchers too; they are lazily
- // re-registered by `getLspServersForDir` as directories are re-cached.
- for (const watcher of this.lspDirWatchers.values()) watcher.close();
- this.lspDirWatchers.clear();
- // Re-discover Claude accounts: a config reload may accompany freshly
- // imported credentials, and (critically) lets a process that failed
- // account discovery at boot recover without a full restart.
- this._refreshClaudeAccounts();
- // Invalidate cached agents so next message uses updated config
- for (const tabAgent of this.tabAgents.values()) {
- tabAgent.agent = null;
- }
- // Emit config-reload to all tabs (and persist as a system chunk)
- for (const tabId of this.tabAgents.keys()) {
- this.emit({ type: "config-reload" }, tabId);
- this.routeSystemEventToTab(tabId, "config-reload", "Configuration reloaded");
- }
- });
-
- this.skillsWatcher = createSkillsWatcher(workingDirectory, (result) => {
- this.skillsData = result;
- // Invalidate cached agents so next message uses updated skills
- for (const tabAgent of this.tabAgents.values()) {
- tabAgent.agent = null;
- }
- // Emit config-reload to all tabs (and persist as a system chunk)
- for (const tabId of this.tabAgents.keys()) {
- this.emit({ type: "config-reload" }, tabId);
- this.routeSystemEventToTab(tabId, "config-reload", "Skills reloaded");
- }
- });
- }
-
- private _refreshClaudeAccounts(): void {
- try {
- this.claudeAccounts = getClaudeAccountsFromDB();
- if (this.claudeAccounts.length > 0) {
- console.log(`dispatch: discovered ${this.claudeAccounts.length} Claude account(s)`);
- }
- } catch (err) {
- console.warn(
- `dispatch: failed to discover Claude accounts: ${err instanceof Error ? err.message : String(err)}`,
- );
- }
- }
-
- /**
- * Resolve (and cache) the LSP servers configured for a working directory.
- *
- * LSP config is resolved by `loadConfig`, which merges the HOME-directory
- * global `dispatch.toml` (`~/.config/dispatch/dispatch.toml`) underneath the
- * tab's effective working-directory `dispatch.toml` — local `[lsp.<id>]`
- * entries override global ones sharing the same id, while global-only
- * servers stay active in every repository. We read+merge that config once
- * per directory and cache the resolved servers; the cache is cleared on
- * config hot-reload. Returns `[]` when neither config declares an `[lsp]`
- * block (the common case).
- */
- private getLspServersForDir(dir: string): ResolvedLspServer[] {
- const cached = this.lspServersByDir.get(dir);
- if (cached) return cached;
- let servers: ResolvedLspServer[] = [];
- try {
- const dirConfig = loadConfig(dir);
- servers = resolveServersFromConfig(dirConfig.lsp);
- } catch (err) {
- console.warn(
- `dispatch: failed to load LSP config for ${dir}: ${err instanceof Error ? err.message : String(err)}`,
- );
- servers = [];
- }
- this.lspServersByDir.set(dir, servers);
- // Hot-reload for SUBDIRECTORY configs: the root/global watcher in the
- // constructor does not cover a nested `dispatch.toml`. Register a
- // one-per-dir watcher the first time we cache a directory so editing
- // its config invalidates just this entry (and cached agents) without a
- // restart. The root working directory is already covered by
- // `configWatcher`, so skip it to avoid a redundant watch.
- this.ensureLspDirWatcher(dir);
- return servers;
- }
-
- /**
- * Register (once) a file watcher on `<dir>/dispatch.toml` so a change to a
- * subdirectory config invalidates that directory's LSP cache entry and
- * any cached agents. No-op for the root working directory (already watched
- * by `configWatcher`) and for directories already being watched.
- */
- private ensureLspDirWatcher(dir: string): void {
- if (dir === this.rootWorkingDirectory) return;
- if (this.lspDirWatchers.has(dir)) return;
- const watcher = watchDirConfig(dir, () => {
- // Drop just this directory's resolved servers; the next tool build
- // re-reads (and re-merges global) for it.
- this.lspServersByDir.delete(dir);
- // Invalidate cached agents so the next message rebuilds tools with
- // the updated server set.
- for (const tabAgent of this.tabAgents.values()) {
- tabAgent.agent = null;
- }
- for (const tabId of this.tabAgents.keys()) {
- this.emit({ type: "config-reload" }, tabId);
- this.routeSystemEventToTab(tabId, "config-reload", "Configuration reloaded");
- }
- });
- this.lspDirWatchers.set(dir, watcher);
- }
-
- /**
- * Build the `onAfterWrite` hook for `createWriteFileTool` when the tab's
- * working directory has LSP servers configured. The hook touches the
- * just-written file through the LSP and returns a formatted diagnostics
- * block (the written file's errors first, then a small cap of other-file
- * errors) — opencode's diagnostics-on-write pattern. Returns `undefined`
- * when no server matches, so writes stay zero-overhead for non-LSP files.
- */
- private buildAfterWriteHook(
- workingDirectory: string,
- servers: ResolvedLspServer[],
- ): ((absolutePath: string) => Promise<string>) | undefined {
- if (servers.length === 0) return undefined;
- const manager = this.lspManager;
- return async (absolutePath: string): Promise<string> => {
- if (!manager.hasServerForFile(absolutePath, servers)) return "";
- await manager.touchFile({
- file: absolutePath,
- root: workingDirectory,
- servers,
- mode: "document",
- });
- const diagnostics = manager.getDiagnostics({
- root: workingDirectory,
- servers,
- file: absolutePath,
- });
- let output = "";
- let otherFileCount = 0;
- for (const [file, issues] of Object.entries(diagnostics)) {
- const current = file === absolutePath;
- if (!current && otherFileCount >= MAX_LSP_OTHER_FILE_DIAGNOSTICS) continue;
- const block = reportDiagnostics(file, issues);
- if (!block) continue;
- if (current) {
- output += `${output ? "\n\n" : ""}LSP errors detected in this file, please fix:\n${block}`;
- } else {
- otherFileCount++;
- output += `${output ? "\n\n" : ""}LSP errors detected in other files:\n${block}`;
- }
- }
- return output;
- };
- }
-
- private _initModelRegistry(config: DispatchConfig): void {
- if (config.keys) {
- if (this.modelRegistry) {
- this.modelRegistry.updateConfig(config.keys);
- } else {
- this.modelRegistry = new ModelRegistry(config.keys);
- }
- } else {
- this.modelRegistry = null;
- }
- }
-
- getPermissionManager(): PermissionManager | undefined {
- return this.permissionManager;
- }
-
- /** Get the TaskList for a specific tab (creates the tab entry if missing). */
- getTaskList(tabId: string): TaskList {
- return this._getOrCreateTabAgent(tabId).taskList;
- }
-
- getClaudeAccounts(): ClaudeAccount[] {
- return this.claudeAccounts;
- }
-
- /** Get or create the TabAgent entry for a tab (without creating an Agent). */
- private _getOrCreateTabAgent(tabId: string): TabAgent {
- let tabAgent = this.tabAgents.get(tabId);
- if (!tabAgent) {
- const taskList = new TaskList();
- taskList.onChange((tasks) => {
- this.emit({ type: "task-list-update", tasks }, tabId);
- });
- tabAgent = {
- agent: null,
- status: "idle",
- keyId: null,
- modelId: null,
- taskList,
- messageQueue: [],
- queueListeners: [],
- shellStore: new BackgroundShellStore(),
- transcriptStore: new BackgroundTranscriptStore(),
- currentChunks: null,
- currentAssistantId: null,
- currentTurnId: null,
- autoWakeBudget: MAX_AGENT_AUTO_WAKES,
- };
- this.tabAgents.set(tabId, tabAgent);
- }
- return tabAgent;
- }
-
- private async getOrCreateAgentForTab(
- tabId: string,
- keyId?: string,
- modelId?: string,
- ): Promise<Agent> {
- const tabAgent = this._getOrCreateTabAgent(tabId);
-
- // Determine effective override: use provided values, or fall back to stored per-tab values
- const effectiveKeyId = keyId ?? tabAgent.keyId;
- const effectiveModelId = modelId ?? tabAgent.modelId;
-
- // Read tool permission settings from DB (default: read=allow, edit=ask, bash=ask, summon=ask, web=ask, youtube=ask)
- const permRead = getSetting("perm_read") !== "ask";
- const permEdit = getSetting("perm_edit") === "allow";
- const permBash = getSetting("perm_bash") === "allow";
- const permSummon = getSetting("perm_summon") === "allow";
- const permUserAgent = getSetting("perm_user_agent") === "allow";
- const permSendToTab = getSetting("perm_send_to_tab") === "allow";
- const permReadTab = getSetting("perm_read_tab") === "allow";
- const permWebSearch = getSetting("perm_web_search") === "allow";
- const permSearchCode = getSetting("perm_search_code") === "allow";
- const permKeyUsage = getSetting("perm_key_usage") === "allow";
- const permYoutubeTranscribe = getSetting("perm_youtube_transcribe") === "allow";
- const permLsp = getSetting("perm_lsp") === "allow";
- const sysPrompt = getSetting("system_prompt") ?? "";
- const permKey = `${permRead}:${permEdit}:${permBash}:${permSummon}:${permUserAgent}:${permSendToTab}:${permReadTab}:${permWebSearch}:${permYoutubeTranscribe}:${permSearchCode}:${permKeyUsage}:${permLsp}:${sysPrompt}`;
-
- // If the override differs or permissions changed, invalidate the cached agent
- if (
- tabAgent.agent &&
- (effectiveKeyId !== tabAgent.keyId ||
- effectiveModelId !== tabAgent.modelId ||
- permKey !== tabAgent._lastPermKey)
- ) {
- tabAgent.agent = null;
- }
-
- if (!tabAgent.agent) {
- const defaultWorkDir = process.env.DISPATCH_WORKING_DIR ?? process.cwd();
- let workingDirectory = tabAgent.workingDirectoryOverride ?? defaultWorkDir;
-
- // Expand ~ to home directory
- if (workingDirectory === "~" || workingDirectory.startsWith("~/")) {
- const { homedir } = await import("node:os");
- const { join } = await import("node:path");
- workingDirectory = join(homedir(), workingDirectory.slice(1));
- }
-
- // Resolve relative paths against the default working directory
- // (e.g. subagent cwd "./subtask" resolves relative to the parent's effective dir)
- {
- const { isAbsolute, resolve } = await import("node:path");
- if (!isAbsolute(workingDirectory)) {
- workingDirectory = resolve(defaultWorkDir, workingDirectory);
- }
- }
-
- // Auto-create the working directory if it doesn't exist
- try {
- const { mkdirSync, existsSync } = await import("node:fs");
- if (!existsSync(workingDirectory)) {
- mkdirSync(workingDirectory, { recursive: true });
- }
- } catch {
- // Ignore — tool execution will surface the error naturally
- }
-
- // Resolve LSP servers for this working directory once (cached).
- // Drives both diagnostics-on-write (the write_file hook) and the
- // optional `lsp` tool. Empty for directories with no `[lsp]` block.
- const lspServers = this.getLspServersForDir(workingDirectory);
- const afterWriteHook = this.buildAfterWriteHook(workingDirectory, lspServers);
-
- // Build tools list — child agents use their toolsOverride whitelist,
- // parent agents use permission settings from DB
- const toolEntries: Array<{ name: string; tool: ReturnType<typeof createReadFileTool> }> = [];
-
- if (tabAgent.toolsOverride) {
- // Child agent: use explicit tool whitelist
- const allowed = new Set(tabAgent.toolsOverride);
- if (allowed.has("read_file")) {
- toolEntries.push({ name: "read_file", tool: createReadFileTool(workingDirectory) });
- // read_file_slice is a companion to read_file — only useful for
- // inspecting long lines that read_file truncated. Ship them together.
- toolEntries.push({
- name: "read_file_slice",
- tool: createReadFileSliceTool(workingDirectory),
- });
- // list_files is bundled with read access
- if (allowed.has("list_files")) {
- toolEntries.push({ name: "list_files", tool: createListFilesTool(workingDirectory) });
- }
- }
- if (allowed.has("list_files") && !allowed.has("read_file")) {
- toolEntries.push({ name: "list_files", tool: createListFilesTool(workingDirectory) });
- }
- if (allowed.has("write_file")) {
- toolEntries.push({
- name: "write_file",
- tool: createWriteFileTool(workingDirectory, afterWriteHook),
- });
- }
- if (allowed.has("run_shell")) {
- toolEntries.push({
- name: "run_shell",
- tool: createRunShellTool(workingDirectory, tabAgent.shellStore),
- });
- }
- if (allowed.has("search_code")) {
- toolEntries.push({
- name: "search_code",
- tool: createSearchCodeTool(workingDirectory),
- });
- }
- if (allowed.has("web_search")) {
- toolEntries.push({ name: "web_search", tool: createWebSearchTool() });
- }
- if (allowed.has("key_usage")) {
- toolEntries.push({ name: "key_usage", tool: this.buildKeyUsageTool() });
- }
- if (allowed.has("lsp") && lspServers.length > 0) {
- toolEntries.push({
- name: "lsp",
- tool: createLspTool(() => ({
- manager: this.lspManager,
- workingDirectory,
- servers: lspServers,
- })),
- });
- }
- if (allowed.has("youtube_transcribe")) {
- toolEntries.push({
- name: "youtube_transcribe",
- tool: createYoutubeTranscribeTool(tabAgent.transcriptStore),
- });
- }
- if (allowed.has("todo")) {
- toolEntries.push({ name: "todo", tool: createTaskListTool(tabAgent.taskList) });
- }
- if (allowed.has("summon")) {
- const childParentAllowedTools = new Set(toolEntries.map((e) => e.name));
- const allAgentDefs = loadAgents(workingDirectory);
- const availableSubagents = toAvailableSubagents(
- allAgentDefs,
- GLOBAL_AGENTS_DIR,
- workingDirectory,
- );
- const availableUserAgents = toAvailableUserAgents(
- allAgentDefs,
- GLOBAL_AGENTS_DIR,
- workingDirectory,
- );
- const agentDirPaths = getAgentDirPaths(workingDirectory);
- toolEntries.push({
- name: "summon",
- tool: createSummonTool(
- workingDirectory,
- {
- spawn: (opts) =>
- this.spawnChildAgent({
- ...opts,
- parentKeyId: tabAgent.keyId,
- parentModelId: tabAgent.modelId,
- parentAllowedTools: childParentAllowedTools,
- parentTabId: tabId,
- }),
- getResult: (id) => this.getChildResult(id),
- },
- availableSubagents,
- availableUserAgents,
- agentDirPaths,
- permUserAgent,
- ),
- });
- }
- if (allowed.has("retrieve")) {
- toolEntries.push({
- name: "retrieve",
- tool: createRetrieveTool({
- getResult: (id) =>
- tabAgent.shellStore.has(id)
- ? tabAgent.shellStore.getResult(id)
- : tabAgent.transcriptStore.has(id)
- ? tabAgent.transcriptStore.getResult(id)
- : this.getChildResult(id),
- }),
- });
- }
- // Tab-to-tab communication — gated on the child whitelist.
- if (allowed.has("send_to_tab") || allowed.has("read_tab")) {
- for (const entry of this.buildTabCommToolEntries(tabId, allowed.has("read_tab"))) {
- if (allowed.has(entry.name)) toolEntries.push(entry);
- }
- }
- } else {
- // Parent agent: use permission settings from DB
- if (permRead) {
- toolEntries.push({ name: "read_file", tool: createReadFileTool(workingDirectory) });
- toolEntries.push({
- name: "read_file_slice",
- tool: createReadFileSliceTool(workingDirectory),
- });
- toolEntries.push({ name: "list_files", tool: createListFilesTool(workingDirectory) });
- }
- if (permEdit) {
- toolEntries.push({
- name: "write_file",
- tool: createWriteFileTool(workingDirectory, afterWriteHook),
- });
- }
- if (permBash) {
- toolEntries.push({
- name: "run_shell",
- tool: createRunShellTool(workingDirectory, tabAgent.shellStore),
- });
- }
- if (permSearchCode) {
- toolEntries.push({
- name: "search_code",
- tool: createSearchCodeTool(workingDirectory),
- });
- }
- if (permWebSearch) {
- toolEntries.push({ name: "web_search", tool: createWebSearchTool() });
- }
- if (permKeyUsage) {
- toolEntries.push({ name: "key_usage", tool: this.buildKeyUsageTool() });
- }
- // The `lsp` tool exposes diagnostics + navigation on demand. It is
- // gated by `perm_lsp` AND requires at least one server configured
- // in the working directory's `dispatch.toml`.
- if (permLsp && lspServers.length > 0) {
- toolEntries.push({
- name: "lsp",
- tool: createLspTool(() => ({
- manager: this.lspManager,
- workingDirectory,
- servers: lspServers,
- })),
- });
- }
- if (permYoutubeTranscribe) {
- toolEntries.push({
- name: "youtube_transcribe",
- tool: createYoutubeTranscribeTool(tabAgent.transcriptStore),
- });
- }
- toolEntries.push({ name: "todo", tool: createTaskListTool(tabAgent.taskList) });
- // The `summon` tool is registered when EITHER the subagent
- // permission (`perm_summon`) OR the user-agent permission
- // (`perm_user_agent`) is granted — the two are independent.
- // `perm_summon` enables ordinary subagent spawning; granting
- // only `perm_user_agent` exposes summon in user-agent-only mode
- // (spawns top-level user agents exclusively).
- if (permSummon || permUserAgent) {
- // Capture parent's allowed tool names for child permission enforcement
- const parentAllowedTools = new Set(toolEntries.map((e) => e.name));
- const allAgentDefs = loadAgents(workingDirectory);
- const availableSubagents = toAvailableSubagents(
- allAgentDefs,
- GLOBAL_AGENTS_DIR,
- workingDirectory,
- );
- const availableUserAgents = toAvailableUserAgents(
- allAgentDefs,
- GLOBAL_AGENTS_DIR,
- workingDirectory,
- );
- const agentDirPaths = getAgentDirPaths(workingDirectory);
- toolEntries.push({
- name: "summon",
- tool: createSummonTool(
- workingDirectory,
- {
- spawn: (opts) =>
- this.spawnChildAgent({
- ...opts,
- parentKeyId: tabAgent.keyId,
- parentModelId: tabAgent.modelId,
- parentAllowedTools,
- parentTabId: tabId,
- }),
- getResult: (id) => this.getChildResult(id),
- },
- availableSubagents,
- availableUserAgents,
- agentDirPaths,
- permUserAgent,
- permSummon,
- ),
- });
- // `retrieve` collects subagent results. User agents are
- // fire-and-forget, so it is bundled with the subagent
- // permission only — a user-agent-only grant doesn't get it.
- if (permSummon) {
- toolEntries.push({
- name: "retrieve",
- tool: createRetrieveTool({
- getResult: (id) =>
- tabAgent.shellStore.has(id)
- ? tabAgent.shellStore.getResult(id)
- : tabAgent.transcriptStore.has(id)
- ? tabAgent.transcriptStore.getResult(id)
- : this.getChildResult(id),
- }),
- });
- }
- }
- if (permSendToTab || permReadTab) {
- const tabCommAllowed = new Set<string>();
- if (permSendToTab) tabCommAllowed.add("send_to_tab");
- if (permReadTab) tabCommAllowed.add("read_tab");
- for (const entry of this.buildTabCommToolEntries(tabId, permReadTab)) {
- if (tabCommAllowed.has(entry.name)) toolEntries.push(entry);
- }
- }
- }
-
- const tools = toolEntries.map((e) => e.tool);
- const toolNames = toolEntries.map((e) => e.name);
- tabAgent._lastPermKey = permKey;
-
- const ruleset = configToRuleset(this.config);
-
- // Try to resolve model from registry, fall back to env vars
- let apiKey = "";
- let model = "deepseek-v4-flash";
- let baseURL = "https://opencode.ai/zen/go/v1";
- let provider: string | undefined;
- let claudeCredentials: { accessToken: string } | undefined;
-
- let useOverride = false;
-
- if (effectiveKeyId && effectiveModelId && this.modelRegistry) {
- // Direct override: look up the key by id in the registry
- const keyState = this.modelRegistry
- .getKeys()
- .find((k) => k.definition.id === effectiveKeyId);
- if (keyState) {
- const key = keyState.definition;
- if (key.provider === "anthropic") {
- // Anthropic provider: resolve credentials from Claude accounts
- const credFile = key.credentials_file;
- const findAccount = () =>
- this.claudeAccounts.find((a) => a.id === effectiveKeyId) ??
- (credFile
- ? this.claudeAccounts.find((a) => a.source === credFile)
- : this.claudeAccounts[0]);
- let account = findAccount();
- // Self-heal: account discovery runs once at construction and can
- // fail at boot (e.g. the data dir isn't mounted yet and
- // getDatabase() throws EACCES), leaving claudeAccounts empty for
- // the process lifetime. If the lookup fails, re-run discovery now
- // that the DB is reachable and retry before giving up.
- if (!account) {
- this._refreshClaudeAccounts();
- account = findAccount();
- }
- if (account) {
- const creds = refreshAccountCredentials(account);
- if (creds && creds.expiresAt > Date.now() + 60_000) {
- claudeCredentials = { accessToken: creds.accessToken };
- apiKey = creds.accessToken;
- baseURL = key.base_url;
- model = effectiveModelId;
- provider = "anthropic";
- tabAgent.keyId = effectiveKeyId;
- tabAgent.modelId = effectiveModelId;
- useOverride = true;
- } else {
- // Token expired — await the async refresh
- const fresh = await refreshAccountCredentialsAsync(account);
- if (fresh && fresh.expiresAt > Date.now() + 60_000) {
- account.credentials = fresh;
- claudeCredentials = { accessToken: fresh.accessToken };
- apiKey = fresh.accessToken;
- baseURL = key.base_url;
- model = effectiveModelId;
- provider = "anthropic";
- tabAgent.keyId = effectiveKeyId;
- tabAgent.modelId = effectiveModelId;
- useOverride = true;
- } else {
- console.warn(
- `dispatch: unable to refresh Claude credentials for "${account.label}" — using stale token`,
- );
- claudeCredentials = { accessToken: account.credentials.accessToken };
- apiKey = account.credentials.accessToken;
- baseURL = key.base_url;
- model = effectiveModelId;
- provider = "anthropic";
- tabAgent.keyId = effectiveKeyId;
- tabAgent.modelId = effectiveModelId;
- useOverride = true;
- }
- }
- } else {
- console.warn(`dispatch: no Claude credentials found for key "${key.id}"`);
- }
- } else {
- // Standard key: resolve from env var
- const envKey = resolveApiKey(key.id, key.env);
- if (envKey) {
- apiKey = envKey;
- baseURL = key.base_url;
- model = effectiveModelId;
- // OpenCode Go splits its catalog across two endpoints:
- // `/chat/completions` — GLM, Kimi, DeepSeek, MiMo (OpenAI-compatible)
- // `/messages` — MiniMax, Qwen (Anthropic-format)
- // The configured key has provider="opencode-go" which defaults to
- // the OpenAI-compatible path. When the selected model lives on the
- // `/messages` route, route through the API-key Anthropic provider
- // instead so the SDK targets the correct endpoint and protocol.
- if (key.provider === "opencode-go" && isOpencodeGoAnthropicModel(model)) {
- provider = "opencode-anthropic";
- }
- tabAgent.keyId = effectiveKeyId;
- tabAgent.modelId = effectiveModelId;
- useOverride = true;
- } else {
- console.warn(
- `dispatch: env var "${key.env}" not set for key "${key.id}", falling back to env vars`,
- );
- // Apply the correct model + baseURL even when the key
- // is unavailable so the request at least targets the
- // right endpoint and produces a diagnosable auth error
- // instead of silently routing to the default OpenCode Go
- // endpoint (which may serve a different model).
- baseURL = key.base_url;
- model = effectiveModelId;
- tabAgent.keyId = effectiveKeyId;
- tabAgent.modelId = effectiveModelId;
- useOverride = true;
- }
- }
- } else {
- console.warn(`dispatch: key "${effectiveKeyId}" not found in model registry`);
- }
- }
-
- if (!useOverride) {
- // Clear any previous override when falling back to default resolution
- tabAgent.keyId = null;
- tabAgent.modelId = null;
- }
-
- const customSystemPrompt = getSetting("system_prompt") || undefined;
- tabAgent.agent = new Agent(
- {
- model,
- apiKey,
- baseURL,
- systemPrompt: buildSystemPrompt(toolNames, customSystemPrompt),
- tools,
- workingDirectory,
- permissionChecker: this.permissionManager ?? undefined,
- ruleset,
- provider,
- tabId,
- ...(claudeCredentials ? { claudeCredentials } : {}),
- },
- {
- dequeueMessages: () => this.dequeueMessages(tabId),
- waitForQueuedMessage: () => this.waitForQueuedMessage(tabId),
- },
- );
-
- // Pre-populate the Agent's in-memory message history from the DB
- // so prior turns survive Agent recreation. The Agent is
- // constructed fresh here in three scenarios that ALL discard
- // the previous in-memory `messages` array:
- // 1. First call for this tab (no prior Agent existed)
- // 2. Model/key/permission/working-directory change — the
- // invalidation gate above set `tabAgent.agent = null`.
- // This is the model-switcher-slider case: without this
- // pre-population, DeepSeek would see zero context after
- // switching from Opus mid-conversation.
- // 3. Config or skills reload (configWatcher / skillsWatcher
- // also null out `tabAgent.agent`).
- //
- // Boundary semantics: `processMessage` appends the current turn's
- // user message (as a chunk row) BEFORE calling this function, so the
- // grouped history ends in `[..., u_current]`. In the fallback retry
- // path the previous attempt may also have flushed a partial assistant
- // turn, so it can end `[..., u_current, partial_a]`. Either way, we
- // walk backwards to the most recent user-role message and load only
- // strictly-prior messages: `agent.run()` pushes the current user
- // message itself, so including it here would duplicate it.
- //
- // `toModelMessages` already filters out `role === "system"`
- // rows and strips `error` / `system` chunks, so it's safe to
- // load system messages verbatim.
- try {
- const rows = getMessagesForTab(tabId);
- let cutIdx = rows.length;
- for (let i = rows.length - 1; i >= 0; i--) {
- const row = rows[i];
- if (row && row.role === "user") {
- cutIdx = i;
- break;
- }
- }
- if (cutIdx > 0) {
- tabAgent.agent.messages = rows
- .slice(0, cutIdx)
- .map((r) => ({ role: r.role, chunks: r.chunks }));
- }
- } catch {
- // DB read failed — leave `messages: []`. The agent still
- // works, just without prior history (matches pre-fix
- // behaviour, so this is no worse than what we had before).
- }
- }
- return tabAgent.agent;
- }
-
- /**
- * Resolve connection parameters (apiKey / baseURL / model / provider /
- * Claude OAuth credentials) for a key+model pair WITHOUT mutating any tab
- * state. Mirrors the resolution in `getOrCreateAgentForTab` (Anthropic
- * account refresh, env-var keys, OpenCode-Go anthropic-route detection) but
- * is side-effect-free so it can be reused by compaction. Returns `null` when
- * the key/model can't be resolved to a usable connection.
- */
- private async resolveConnection(
- keyId: string,
- modelId: string,
- ): Promise<{
- apiKey: string;
- baseURL: string;
- model: string;
- provider?: string;
- claudeCredentials?: { accessToken: string };
- } | null> {
- if (!keyId || !modelId || !this.modelRegistry) return null;
- const keyState = this.modelRegistry.getKeys().find((k) => k.definition.id === keyId);
- if (!keyState) return null;
- const key = keyState.definition;
-
- if (key.provider === "anthropic") {
- const credFile = key.credentials_file;
- const findAccount = () =>
- this.claudeAccounts.find((a) => a.id === keyId) ??
- (credFile
- ? this.claudeAccounts.find((a) => a.source === credFile)
- : this.claudeAccounts[0]);
- let account = findAccount();
- if (!account) {
- this._refreshClaudeAccounts();
- account = findAccount();
- }
- if (!account) return null;
- let creds = refreshAccountCredentials(account);
- if (!creds || creds.expiresAt <= Date.now() + 60_000) {
- const fresh = await refreshAccountCredentialsAsync(account);
- if (fresh) {
- account.credentials = fresh;
- creds = fresh;
- }
- }
- const accessToken = creds?.accessToken ?? account.credentials.accessToken;
- return {
- apiKey: accessToken,
- baseURL: key.base_url,
- model: modelId,
- provider: "anthropic",
- claudeCredentials: { accessToken },
- };
- }
-
- // Standard key resolved from env var.
- const envKey = resolveApiKey(key.id, key.env);
- if (!envKey) return null;
- let provider: string | undefined;
- if (key.provider === "opencode-go" && isOpencodeGoAnthropicModel(modelId)) {
- provider = "opencode-anthropic";
- }
- return { apiKey: envKey, baseURL: key.base_url, model: modelId, provider };
- }
-
- /**
- * Resolve the compactor model: the configured `compaction_model_*` setting
- * when present, otherwise fall back to the source tab's own key+model. Used
- * to run the summary generation request.
- */
- private resolveCompactorKeyModel(sourceTabId: string): { keyId: string; modelId: string } | null {
- const cfgKey = getSetting("compaction_model_key_id");
- const cfgModel = getSetting("compaction_model_id");
- if (cfgKey && cfgModel) return { keyId: cfgKey, modelId: cfgModel };
- const tabAgent = this.tabAgents.get(sourceTabId);
- const row = getTab(sourceTabId);
- const keyId = tabAgent?.keyId ?? row?.keyId ?? null;
- const modelId = tabAgent?.modelId ?? row?.modelId ?? null;
- if (keyId && modelId) return { keyId, modelId };
- return null;
- }
-
- /**
- * Run a one-shot, tool-less summary generation using a transient Agent. The
- * Agent loop handles Claude-OAuth billing/identity/caching correctly. The
- * prompt is the entire summary request (transcript + template); no tools are
- * registered so the model can only produce text. Returns the concatenated
- * assistant text, or throws on error/abort.
- */
- private async generateSummary(
- conn: {
- apiKey: string;
- baseURL: string;
- model: string;
- provider?: string;
- claudeCredentials?: { accessToken: string };
- },
- prompt: string,
- abortSignal: AbortSignal,
- ): Promise<string> {
- const agent = new Agent({
- model: conn.model,
- apiKey: conn.apiKey,
- baseURL: conn.baseURL,
- systemPrompt:
- "You are a conversation-summarization assistant. Follow the user's instructions and output ONLY the requested Markdown summary.",
- tools: [],
- workingDirectory: process.env.DISPATCH_WORKING_DIR ?? process.cwd(),
- provider: conn.provider,
- ...(conn.claudeCredentials ? { claudeCredentials: conn.claudeCredentials } : {}),
- });
- let out = "";
- let errored: string | null = null;
- for await (const event of agent.run(prompt, { abortSignal })) {
- if (abortSignal.aborted) break;
- if (event.type === "text-delta") out += event.delta;
- else if (event.type === "error") errored = event.error;
- }
- if (abortSignal.aborted) throw new Error("Compaction cancelled");
- if (errored) throw new Error(errored);
- const trimmed = out.trim();
- if (!trimmed) throw new Error("Compaction produced an empty summary");
- return trimmed;
- }
-
- /**
- * Compact a conversation (UI-driven). Summarizes the older "head" of
- * `sourceTabId` into an anchored Markdown summary while preserving the last
- * N turns verbatim, then performs the id-relocation the product requires:
- *
- * - The FULL pre-compaction history is moved to a fresh `backupTabId`
- * (so nothing is destroyed — fully reversible).
- * - `sourceTabId` (the canonical id, with its key/model/working-dir/agent
- * and the global tool permissions intact) is re-seeded with the summary
- * turn + the preserved tail.
- *
- * `tempTabId` is the frontend placeholder tab hosting the "compacting…"
- * message; it is discarded on completion. Cancellation = the caller aborts
- * via `tempTabId`'s abort controller (e.g. closing the placeholder tab).
- *
- * Returns when the compaction settles; emits `compaction-started`,
- * `compaction-complete`, or `compaction-error`.
- */
- async compactTab(tempTabId: string, sourceTabId: string): Promise<void> {
- const tempAgent = this._getOrCreateTabAgent(tempTabId);
- const abortController = new AbortController();
- tempAgent.abortController = abortController;
-
- const fail = (error: string): void => {
- const src = this.tabAgents.get(sourceTabId);
- if (src) src.compacting = false;
- this.emit({ type: "compaction-error", tempTabId, sourceTabId, error }, tempTabId);
- // Drain anything queued on the source while it was locked.
- this.continueFromQueue(sourceTabId);
- };
-
- try {
- // Refuse to compact a running tab (turn must have ended).
- if (this.getTabStatus(sourceTabId) === "running") {
- fail("Cannot compact while a turn is in progress.");
- return;
- }
-
- // Lock the source so new messages queue instead of starting turns.
- const sourceAgent = this._getOrCreateTabAgent(sourceTabId);
- sourceAgent.compacting = true;
- this.emit({ type: "compaction-started", tempTabId, sourceTabId }, tempTabId);
-
- // Read the full history as grouped messages (preserves turnId/seq).
- const rows = groupRowsToMessages(getChunksForTab(sourceTabId));
- const { tail, prompt } = buildCompactionRequest({ messages: rows });
- if (!prompt) {
- fail("Not enough conversation history to compact.");
- return;
- }
-
- // Resolve the compactor model (configured, else source tab's own).
- const compactor = this.resolveCompactorKeyModel(sourceTabId);
- if (!compactor) {
- fail("No model available to run compaction. Configure a compaction model in Settings.");
- return;
- }
- const conn = await this.resolveConnection(compactor.keyId, compactor.modelId);
- if (!conn) {
- fail("Could not resolve credentials for the compaction model.");
- return;
- }
-
- // Generate the summary (abortable).
- const summary = await this.generateSummary(conn, prompt, abortController.signal);
- if (abortController.signal.aborted) {
- fail("Compaction cancelled");
- return;
- }
-
- // Relocate the FULL history to a backup tab, then re-seed the source.
- const sourceRow = getTab(sourceTabId);
- const backupTabId = crypto.randomUUID();
- const baseTitle = sourceRow?.title ?? "Conversation";
- const backupTitle = `${baseTitle} (pre-compaction)`;
- createTab(backupTabId, backupTitle, {
- keyId: sourceRow?.keyId ?? null,
- modelId: sourceRow?.modelId ?? null,
- });
- rekeyChunks(sourceTabId, backupTabId);
-
- // Re-seed the canonical (source) id: a summary user turn followed by
- // the preserved tail rows (turnId/step/role/type/data preserved).
- const summaryTurnId = crypto.randomUUID();
- appendChunks(sourceTabId, explodeUserText(summaryTurnId, buildSummaryTurnText(summary)));
- for (const msg of tail) {
- const drafts = explodeTurn(msg.turnId, msg.chunks);
- if (msg.role === "user") {
- // groupRowsToMessages collapses a user message to a single text
- // chunk; explodeTurn only handles assistant/system shapes, so
- // rebuild the user row explicitly.
- const text = msg.chunks.find((c) => c.type === "text");
- appendChunks(
- sourceTabId,
- explodeUserText(msg.turnId, text && text.type === "text" ? text.text : ""),
- );
- continue;
- }
- if (drafts.length > 0) appendChunks(sourceTabId, drafts);
- }
-
- // Reset the source Agent so its in-memory history reloads from the
- // freshly re-seeded chunk log on the next turn.
- sourceAgent.agent = null;
- sourceAgent.compacting = false;
-
- this.emit(
- { type: "compaction-complete", tempTabId, sourceTabId, backupTabId, backupTitle },
- sourceTabId,
- );
- // Drain any messages queued while the source was locked.
- this.continueFromQueue(sourceTabId);
- } catch (err) {
- if (abortController.signal.aborted) {
- fail("Compaction cancelled");
- return;
- }
- fail(err instanceof Error ? err.message : String(err));
- } finally {
- // The placeholder tab is transient; drop its in-memory agent state.
- this.tabAgents.delete(tempTabId);
- }
- }
-
- getTabStatus(tabId: string): AgentStatus {
- return this.tabAgents.get(tabId)?.status ?? "idle";
- }
-
- /**
- * Prompt-cache WARMING for an idle tab (see `Agent.warmCache`).
- *
- * Reconstructs the tab's genuine conversation from the persisted chunk log,
- * resolves the SAME agent (model/key/tools/system prompt) the next real turn
- * would use, and replays the exact cached prefix plus one trivial throwaway
- * turn so the provider's ~5-min prompt-cache TTL is refreshed. The warming
- * request and its response are NOT persisted, NOT emitted, and NOT folded
- * into the real usage aggregate — its `usage` is returned to the caller so a
- * warming-only "last request" cache rate can be shown without polluting the
- * real Cache Rate metric.
- *
- * Refuses to fire while the tab is generating (`running`): the prefix would
- * be mid-mutation and the request would contend with the live turn. Callers
- * gate on idle anyway; this is defence in depth.
- *
- * Returns `{ ok: true, usage }` on success or `{ ok: false, error }` so the
- * route can surface a debug-strip error string. Never throws.
- */
- async warmCacheForTab(
- tabId: string,
- opts: {
- keyId?: string;
- modelId?: string;
- agentModels?: AgentModelEntry[];
- reasoningEffort?: ReasoningEffort;
- } = {},
- ): Promise<{ ok: true; usage: UsageData } | { ok: false; error: string }> {
- if (this.getTabStatus(tabId) === "running") {
- return { ok: false, error: "tab is generating" };
- }
- try {
- const tabAgent = this._getOrCreateTabAgent(tabId);
- if (opts.agentModels) tabAgent.agentModels = opts.agentModels;
-
- // Resolve the agent the next REAL turn would use. The fallback chain's
- // first entry mirrors `processMessage`'s primary attempt; we only warm
- // the primary (warming a fallback model would write a DIFFERENT prefix).
- const fallbackSequence = this.buildFallbackSequence(tabAgent, opts.keyId, opts.modelId);
- const primary = fallbackSequence[0];
- const agent = await this.getOrCreateAgentForTab(
- tabId,
- primary?.key_id || opts.keyId,
- primary?.model_id || opts.modelId,
- );
-
- // Resolve the SAME reasoning effort the next real turn would use:
- // per-model (agent definition) → per-tab selector → Agent default.
- // This drives the thinking providerOptions, which is an Anthropic
- // message-cache key — warming MUST match it or it warms a different
- // cache bucket than the real turn reads (the 0%-on-switch bug).
- const effort = primary?.effort ?? opts.reasoningEffort;
-
- // Rebuild the genuine history exactly as `getOrCreateAgentForTab`'s
- // pre-population does, but keep the FULL history (no trailing-user
- // trim): warming replays the complete cached prefix as-is.
- let history: ChatMessage[] = [];
- try {
- history = getMessagesForTab(tabId).map((r) => ({ role: r.role, chunks: r.chunks }));
- } catch {
- // DB read failed — warm with whatever in-memory history the agent has.
- history = [...agent.messages];
- }
-
- const usage = await agent.warmCache(history, {
- ...(effort ? { reasoningEffort: effort } : {}),
- });
- return { ok: true, usage };
- } catch (err) {
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
- }
- }
-
- /**
- * Snapshot of every tab the manager is currently tracking. Sent on WS
- * connect and via GET /status so a freshly-loaded frontend can
- * reconstruct any in-flight assistant turn without missing the chunks
- * that arrived before its WS handshake completed.
- *
- * For each running tab, the snapshot includes:
- * - status: "running"
- * - currentChunks: a defensive shallow copy of `tabAgent.currentChunks`
- * (the live chunk array the streaming loop appends to). The
- * consumer owns this copy and may mutate it freely.
- * - currentAssistantId: the DB id of the in-flight assistant message
- * row. The frontend aligns its local assistant message id with
- * this so the next `done` event lands on the right message.
- *
- * Every tab additionally carries its `tasks` (the current todo list) when
- * non-empty, so a reloaded frontend rehydrates the Tasks panel from the
- * backend rather than blanking it.
- *
- * For idle/error tabs, only `status` (plus any `tasks`) is present. Tabs not in
- * `this.tabAgents` (e.g. tabs in the DB that have never been touched
- * since server start) are absent from the returned record — the
- * caller infers their status from the DB row (always "idle" at rest).
- */
- getAllStatuses(): Record<string, TabStatusSnapshot> {
- const result: Record<string, TabStatusSnapshot> = {};
- for (const [tabId, tabAgent] of this.tabAgents.entries()) {
- const snap: TabStatusSnapshot = { status: tabAgent.status };
- // Include the tab's todo list (for ALL tabs, not just running ones)
- // so a reloaded frontend rehydrates the Tasks panel from the backend
- // instead of blanking it. Omit when empty to keep the payload lean.
- const tasks = tabAgent.taskList.getTasks();
- if (tasks.length > 0) {
- snap.tasks = tasks;
- }
- if (tabAgent.status === "running") {
- if (tabAgent.currentChunks) {
- // Defensive shallow copy: callers may serialize/mutate.
- snap.currentChunks = [...tabAgent.currentChunks];
- }
- if (tabAgent.currentAssistantId) {
- snap.currentAssistantId = tabAgent.currentAssistantId;
- }
- if (tabAgent.currentTurnId) {
- snap.currentTurnId = tabAgent.currentTurnId;
- }
- }
- result[tabId] = snap;
- }
- return result;
- }
-
- /** @deprecated Use getTabStatus(tabId) instead */
- getStatus(): AgentStatus {
- // Return running if any tab is running, otherwise idle
- for (const tabAgent of this.tabAgents.values()) {
- if (tabAgent.status === "running") return "running";
- }
- return "idle";
- }
-
- getMessageCount(): number {
- return this.messageCount;
- }
-
- onEvent(listener: (event: AgentEvent & { tabId: string }) => void): () => void {
- this.eventListeners.add(listener);
- return () => {
- this.eventListeners.delete(listener);
- };
- }
-
- private emit(event: AgentEvent, tabId: string): void {
- for (const listener of this.eventListeners) {
- listener({ ...event, tabId } as AgentEvent & { tabId: string });
- }
- }
-
- /**
- * Persist a system chunk (notice / model-changed / config-reload /
- * cancelled) to a tab's history.
- *
- * If an assistant turn is in flight (`currentChunks` is non-null), the
- * chunk is folded into the in-flight chunk list; it is exploded into a
- * `system` chunk row when the turn flushes.
- *
- * Otherwise we append a standalone `system` chunk row immediately. Adjacent
- * system rows are coalesced back into one system message at group time
- * (`groupRowsToMessages`).
- */
- private routeSystemEventToTab(tabId: string, kind: SystemChunkKind, text: string): void {
- const tabAgent = this.tabAgents.get(tabId);
-
- // Turn in flight → fold into the in-flight chunk list; it is exploded
- // into chunk rows (including this system chunk) when the turn flushes.
- if (tabAgent?.currentChunks) {
- tabAgent.currentChunks.push({ type: "system", kind, text });
- return;
- }
-
- // No turn in flight → persist a standalone system chunk row immediately.
- try {
- const turnId = tabAgent?.currentTurnId ?? crypto.randomUUID();
- appendChunks(tabId, explodeTurn(turnId, [{ type: "system", kind, text }]));
- } catch {
- // DB not available (e.g. tab not yet created) — drop silently.
- }
- }
-
- stopTab(tabId: string): void {
- const tabAgent = this.tabAgents.get(tabId);
- if (tabAgent) {
- // If a turn is in flight, drop a `cancelled` system chunk into the
- // in-flight chunk list so the user sees an explicit "Generation
- // cancelled by user" marker at the cancellation point. It is
- // persisted (as a chunk row) when `processMessage` flushes the
- // aborted turn.
- if (tabAgent.currentChunks) {
- tabAgent.currentChunks.push({
- type: "system",
- kind: "cancelled",
- text: "Generation cancelled by user",
- });
- }
- tabAgent.abortController?.abort();
- tabAgent.status = "idle";
- this.emit({ type: "status", status: "idle" }, tabId);
- tabAgent.agent = null;
- // Resolve any pending completion promise so retrieve doesn't hang
- tabAgent.completionResolve?.({ status: "error", error: "Agent was stopped." });
- }
- }
-
- deleteTab(tabId: string): void {
- this.stopTab(tabId);
- this.tabAgents.delete(tabId);
- // Drop any spilled tool-output files this tab accumulated. Best-effort —
- // errors are swallowed inside the helper. See packages/core/src/tools/truncate.ts.
- clearSpillForTab(tabId);
- }
-
- /**
- * Spawn a child agent in a new tab. Returns the tab ID (agent_id).
- * The child runs asynchronously — use getChildResult to await completion.
- */
- async spawnChildAgent(options: {
- task: string;
- tools: string[];
- workingDirectory?: string;
- /**
- * Optional slug of an `AgentDefinition` to apply. When set, the
- * definition's `tools`, `models`, and `cwd` take precedence over
- * the `tools`/`workingDirectory` passed in `options`. Tools are
- * still intersected with `parentAllowedTools` to prevent a
- * subagent from gaining capabilities its parent doesn't have.
- */
- agentSlug?: string;
- parentKeyId?: string | null;
- parentModelId?: string | null;
- parentAllowedTools?: Set<string>;
- parentTabId?: string;
- /**
- * When true, spawn as an independent top-level "user agent" tab
- * instead of a subagent child tab. User agents have no parent,
- * are persistent, and cannot be retrieved (fire-and-forget).
- */
- topLevel?: boolean;
- }): Promise<string> {
- const tabId = crypto.randomUUID();
- const title = options.task.length > 50 ? `${options.task.slice(0, 47)}...` : options.task;
-
- // Validate working directory is within the parent agent's effective CWD
- const defaultWorkDir = process.env.DISPATCH_WORKING_DIR ?? process.cwd();
- let parentEffectiveDir = options.topLevel
- ? defaultWorkDir
- : options.parentTabId
- ? (this.tabAgents.get(options.parentTabId)?.workingDirectoryOverride ?? defaultWorkDir)
- : defaultWorkDir;
-
- // Expand ~ in parent dir
- if (parentEffectiveDir === "~" || parentEffectiveDir.startsWith("~/")) {
- const { homedir } = await import("node:os");
- const { join } = await import("node:path");
- parentEffectiveDir = join(homedir(), parentEffectiveDir.slice(1));
- }
-
- // Resolve the agent definition (if a slug was supplied) BEFORE
- // computing the effective working directory and tool whitelist.
- // The definition's cwd/tools take precedence over the caller's
- // `workingDirectory`/`tools` parameters, mirroring how a top-level
- // tab picking the same definition would behave.
- let agentDef: ReturnType<typeof loadAgent> = null;
- if (options.agentSlug) {
- agentDef = loadAgent(options.agentSlug, parentEffectiveDir);
- if (!agentDef) {
- const allDefs = loadAgents(parentEffectiveDir);
- if (options.topLevel) {
- const userAgents = allDefs
- .filter((d) => !d.is_subagent)
- .map((d) => `${d.slug} (${d.name})`);
- const hint =
- userAgents.length > 0
- ? ` Available user agents: ${userAgents.join(", ")}.`
- : " No user agent definitions exist yet.";
- throw new Error(`Agent definition not found: "${options.agentSlug}".${hint}`);
- } else {
- const subagents = allDefs
- .filter((d) => d.is_subagent)
- .map((d) => `${d.slug} (${d.name})`);
- const hint =
- subagents.length > 0
- ? ` Available subagents: ${subagents.join(", ")}.`
- : " No subagent definitions exist yet.";
- throw new Error(`Agent definition not found: "${options.agentSlug}".${hint}`);
- }
- }
-
- // Validate that the definition type matches the spawn mode:
- // subagent slugs can't be used with top_level=true, and
- // user-agent slugs can't be used without top_level=true.
- if (options.topLevel && agentDef.is_subagent) {
- throw new Error(
- `Cannot spawn user agent: "${options.agentSlug}" is a subagent definition. Use a non-subagent definition for top_level=true.`,
- );
- }
- if (!options.topLevel && !agentDef.is_subagent) {
- throw new Error(
- `Cannot spawn subagent: "${options.agentSlug}" is a user agent definition. Set top_level=true to spawn it as an independent tab, or use a subagent definition.`,
- );
- }
- }
-
- // Resolve child working directory.
- // Subagents are validated to stay within the parent's effective dir.
- // User agents (topLevel) are free to use any directory.
- const requestedDir = agentDef?.cwd ?? options.workingDirectory;
- let resolvedWorkingDirectory = requestedDir;
- if (requestedDir) {
- const { isAbsolute, relative, resolve, join } = await import("node:path");
- // Expand ~ in child working directory
- let childDir = requestedDir;
- if (childDir === "~" || childDir.startsWith("~/")) {
- const { homedir } = await import("node:os");
- childDir = join(homedir(), childDir.slice(1));
- }
- if (options.topLevel) {
- // User agents: resolve freely, no containment check
- resolvedWorkingDirectory = resolve(defaultWorkDir, childDir);
- } else {
- // Subagents: validate within parent's directory
- const parentDir = resolve(parentEffectiveDir);
- const resolved = resolve(parentDir, childDir);
- const rel = relative(parentDir, resolved);
- const isOutside = rel.startsWith("..") || isAbsolute(rel);
- if (isOutside) {
- throw new Error(
- `Working directory "${requestedDir}" is outside the parent's working directory "${parentDir}".`,
- );
- }
- // Store the resolved absolute path so downstream code doesn't
- // re-resolve against the wrong base directory
- resolvedWorkingDirectory = resolved;
- }
- }
-
- // Determine the child's tool whitelist. When an agent definition
- // was supplied, expand its short permission-group names
- // (read/edit/bash) into concrete tool names. Otherwise use the
- // `tools` parameter verbatim. Either way, intersect with
- // parentAllowedTools so a subagent can't gain capabilities the
- // parent doesn't have — even an agent definition can't escalate.
- const baseTools = agentDef ? expandAgentToolNames(agentDef.tools) : options.tools;
- let childTools = baseTools;
- if (options.parentAllowedTools) {
- childTools = baseTools.filter((t) => options.parentAllowedTools?.has(t));
- }
-
- // Create the tab agent entry with overrides
- const tabAgent = this._getOrCreateTabAgent(tabId);
- tabAgent.toolsOverride = childTools;
- tabAgent.workingDirectoryOverride = resolvedWorkingDirectory;
- tabAgent.finalOutput = "";
-
- const primary = agentDef?.models[0];
- if (agentDef && primary) {
- // The agent definition specifies its own model fallback chain.
- // Set keyId/modelId to the primary (first) model in the chain so
- // the frontend can display the concrete key/model this subagent
- // was configured with, while `agentModels` drives the fallback
- // sequence (matches how a top-level tab using this definition
- // would be configured).
- tabAgent.keyId = primary.key_id;
- tabAgent.modelId = primary.model_id;
- tabAgent.agentModels = agentDef.models;
- } else {
- // No definition (or definition has no models) → inherit from
- // the parent like before.
- tabAgent.keyId = options.parentKeyId ?? null;
- tabAgent.modelId = options.parentModelId ?? null;
- if (options.parentTabId) {
- const parentAgent = this.tabAgents.get(options.parentTabId);
- if (parentAgent?.agentModels) {
- tabAgent.agentModels = parentAgent.agentModels;
- }
- }
- }
-
- // Set up completion tracking — user agents are fire-and-forget,
- // so only subagents get completion promises.
- if (!options.topLevel) {
- tabAgent.completionPromise = new Promise((resolve) => {
- tabAgent.completionResolve = resolve;
- });
- }
-
- // Create tab in DB
- try {
- const { createTab } = await import("@dispatch/core");
- createTab(tabId, title, {
- keyId: tabAgent.keyId,
- modelId: tabAgent.modelId,
- parentTabId: options.topLevel ? undefined : options.parentTabId,
- });
- } catch {
- // Continue even if DB fails
- }
-
- // Notify the frontend about the new tab
- this.emit(
- {
- type: "tab-created",
- id: tabId,
- title,
- keyId: tabAgent.keyId,
- modelId: tabAgent.modelId,
- parentTabId: options.topLevel ? null : (options.parentTabId ?? null),
- agentSlug: options.agentSlug ?? null,
- workingDirectory: resolvedWorkingDirectory ?? null,
- agentModels: tabAgent.agentModels ?? null,
- },
- tabId,
- );
-
- // Start the child agent in the background
- this.processMessage(
- tabId,
- options.task,
- options.parentKeyId ?? undefined,
- options.parentModelId ?? undefined,
- ).catch((err) => {
- const errorMsg = err instanceof Error ? err.message : String(err);
- tabAgent.completionResolve?.({ status: "error", error: errorMsg });
- });
-
- return tabId;
- }
-
- /**
- * Wait for a child agent to finish and return its result.
- * Blocks until the child completes or errors.
- */
- async getChildResult(
- agentId: string,
- ): Promise<{ status: "done"; result: string } | { status: "error"; error: string }> {
- const tabAgent = this.tabAgents.get(agentId);
- if (!tabAgent) {
- return { status: "error", error: `No agent found with id '${agentId}'` };
- }
-
- if (!tabAgent.completionPromise) {
- // Not a child agent or already completed
- if (tabAgent.status === "idle") {
- return { status: "done", result: tabAgent.finalOutput ?? "(no output)" };
- }
- if (tabAgent.status === "running") {
- return {
- status: "error",
- error:
- "This is a user agent (top-level tab) and cannot be retrieved. User agents are fire-and-forget.",
- };
- }
- return {
- status: "error",
- error: "Agent has no completion tracking. It may not have been spawned via summon.",
- };
- }
-
- return tabAgent.completionPromise;
- }
-
- // ─── Tab-to-tab communication ───────────────────────────────────
- //
- // `send_to_tab` / `read_tab` let an agent message a peer tab by its short
- // handle (a git-style prefix of the tab UUID). Delivery reuses the exact
- // running→queue / idle→new-turn routing that `POST /chat` uses (see
- // `deliverMessage`), so an agent message behaves identically to a user one.
-
- /**
- * Build the `key_usage` tool, wired to the live model registry (key states)
- * and the discovered Claude accounts. The tool fetches usage live with a
- * cache fallback (anthropic) or a live scrape (opencode-go), reporting
- * remaining headroom, reset times, and data freshness per key.
- */
- private buildKeyUsageTool(): ReturnType<typeof createKeyUsageTool> {
- return createKeyUsageTool({
- listKeys: () => this.modelRegistry?.getKeys() ?? [],
- listClaudeAccounts: () => this.claudeAccounts,
- });
- }
-
- /**
- * Build the `send_to_tab` + `read_tab` tool entries for `tabId`. Shared by
- * both tool-construction paths (child whitelist + permission-gated parent).
- * `selfHandle` is computed once so the calling tab can stamp provenance and
- * reject self-sends.
- *
- * `canReadTab` reflects whether THIS tab will also be granted `read_tab`
- * (the permissions are split). It is forwarded into `send_to_tab` so the
- * tool only points the agent at `read_tab` when it actually has it — never
- * advertising a tool the agent wasn't granted.
- */
- private buildTabCommToolEntries(
- tabId: string,
- canReadTab: boolean,
- ): Array<{ name: string; tool: ReturnType<typeof createSendToTabTool> }> {
- const selfHandle = shortestUniquePrefix(tabId);
- return [
- {
- name: "send_to_tab",
- tool: createSendToTabTool({
- resolveShortId: (prefix) => this.resolveTabHandle(prefix),
- // origin: "agent" subjects this to the receiver's auto-wake
- // budget so agent↔agent loops are bounded (see deliverMessage).
- deliver: (targetId, message) =>
- this.deliverMessage(targetId, message, { origin: "agent" }),
- listOpenHandles: () => this.listOpenHandles(tabId),
- self: { id: tabId, handle: selfHandle },
- canReadTab,
- }),
- },
- {
- name: "read_tab",
- tool: createReadTabTool({
- resolveShortId: (prefix) => this.resolveTabHandle(prefix),
- getLastResponse: (targetId) => this.getLastTabResponse(targetId),
- listOpenHandles: () => this.listOpenHandles(tabId),
- }),
- },
- ];
- }
-
- /**
- * Project a core `ResolveTabPrefixResult` down to the tool-facing
- * `TabResolution` (minimal `{ id, title, handle }` refs). Each match's
- * `handle` is recomputed via `shortestUniquePrefix` so the value the tool
- * echoes back always matches what the UI currently shows.
- */
- private resolveTabHandle(prefix: string): TabResolution {
- const res = resolveTabPrefix(prefix);
- if (res.status === "none") return { status: "none" };
- if (res.status === "ok") {
- return {
- status: "ok",
- tab: {
- id: res.tab.id,
- title: res.tab.title,
- handle: shortestUniquePrefix(res.tab.id),
- },
- };
- }
- return {
- status: "ambiguous",
- matches: res.matches.map((t) => ({
- id: t.id,
- title: t.title,
- handle: shortestUniquePrefix(t.id),
- })),
- };
- }
-
- /** Snapshot of open tabs as `{ handle, title }`, excluding `exceptId`
- * (typically the caller's own tab). Drives the "available tabs" hints. */
- private listOpenHandles(exceptId?: string): Array<{ handle: string; title: string }> {
- return listOpenTabs()
- .filter((t) => t.id !== exceptId)
- .map((t) => ({ handle: shortestUniquePrefix(t.id), title: t.title }));
- }
-
- /**
- * Return a tab's most recent COMPLETED assistant turn as flat text, plus
- * its current status. Reads the persisted chunk log (source of truth) and
- * grabs the last `role === "assistant"` group's text chunks. `text` is null
- * when no completed assistant turn exists yet.
- */
- getLastTabResponse(tabId: string): { text: string | null; status: AgentStatus } {
- const status = this.getTabStatus(tabId);
- try {
- const messages = getMessagesForTab(tabId);
- for (let i = messages.length - 1; i >= 0; i--) {
- const msg = messages[i];
- if (!msg || msg.role !== "assistant") continue;
- const text = msg.chunks
- .filter((c): c is { type: "text"; text: string } => c.type === "text")
- .map((c) => c.text)
- .join("")
- .trim();
- if (text.length > 0) return { text, status };
- }
- } catch {
- // DB unavailable / tab unknown — fall through to null.
- }
- return { text: null, status };
- }
-
- /**
- * Deliver `message` to `tabId`, choosing the SAME routing as `POST /chat`:
- * - target running → queue it (consumed like a user interrupt).
- * - target idle/errored → wake it and start a new turn.
- *
- * Returns quickly; does NOT block on the turn. Both the HTTP `/chat` path
- * and the `send_to_tab` tool call through here so the running/idle decision
- * lives in exactly one place.
- *
- * `opts` carries the per-request knobs `/chat` forwards (key/model, agent
- * fallback chain, reasoning effort, working dir, an explicit queue id). The
- * `send_to_tab` tool passes none of these — for a cold wake (a tab not in
- * `tabAgents`, e.g. after a server restart) the key/model are hydrated from
- * the live `TabAgent` if present, else from the persisted tab row. (A cold
- * tab keeps its stored key/model but not its full agent-definition fallback
- * chain — see plan notes.)
- */
- deliverMessage(
- tabId: string,
- message: string,
- opts: {
- keyId?: string;
- modelId?: string;
- agentModels?: AgentModelEntry[];
- reasoningEffort?: ReasoningEffort;
- workingDirectory?: string;
- queueId?: string;
- /**
- * Ephemeral ordered multimodal content (image/pdf attachments) for a
- * FRESH human turn. Forwarded to `processMessage` → `agent.run` only
- * when the tab is idle (a started turn); never carried into the queue
- * path (attachments require a fresh turn — the caller guards that).
- */
- content?: UserContentPart[];
- /**
- * Who is sending this message. `"human"` (default) is unrestricted
- * and REFILLS the target's agent-to-agent auto-wake budget. `"agent"`
- * (from the `send_to_tab` tool) is governed by that budget: an
- * agent-originated wake of an idle tab consumes one unit, and once the
- * budget is exhausted the message is queued WITHOUT starting a turn
- * (returned as `suppressed`) so a runaway A↔B loop can't spend tokens
- * forever with no human in the loop.
- */
- origin?: "human" | "agent";
- } = {},
- ): { status: "queued"; messageId: string } | { status: "started" } | { status: "suppressed" } {
- const origin = opts.origin ?? "human";
-
- // A human touching the tab clears any accumulated agent-wake throttle:
- // the conversation is back under human supervision, so peers get a fresh
- // budget of auto-wakes again.
- if (origin === "human") {
- this._getOrCreateTabAgent(tabId).autoWakeBudget = MAX_AGENT_AUTO_WAKES;
- }
-
- if (this.getTabStatus(tabId) === "running") {
- // Busy target → always queue (consumed like a user interrupt),
- // regardless of origin. Queuing does not itself start a turn, so it
- // can't drive a runaway loop; we don't spend budget here.
- const { messageId } = this.queueMessage(tabId, message, opts.queueId);
- return { status: "queued", messageId };
- }
-
- // Tab is mid-compaction → hold the message (queue, never start a turn)
- // until compaction settles. continueFromQueue (called after compaction)
- // drains it onto the compacted continuation.
- if (this.tabAgents.get(tabId)?.compacting) {
- const { messageId } = this.queueMessage(tabId, message, opts.queueId);
- return { status: "queued", messageId };
- }
-
- // Idle/errored target → this delivery would WAKE the tab (start a turn).
- // For agent-originated wakes, enforce the auto-wake budget first.
- if (origin === "agent") {
- const target = this._getOrCreateTabAgent(tabId);
- if (target.autoWakeBudget <= 0) {
- // Budget exhausted: preserve the message (queue it, never drop)
- // but do NOT wake the tab. A human message will refill the budget
- // and the queued message will be seen on the next human turn.
- this.queueMessage(tabId, message, opts.queueId);
- const notice =
- `Automatic agent-to-agent message limit reached for this tab ` +
- `(${MAX_AGENT_AUTO_WAKES} consecutive). Further messages from other tabs ` +
- `are held until you send a message here.`;
- this.emit({ type: "notice", message: notice }, tabId);
- this.routeSystemEventToTab(tabId, "notice", notice);
- return { status: "suppressed" };
- }
- target.autoWakeBudget -= 1;
- }
-
- // Resolve key/model: explicit opts win, then the live tab agent's, then
- // the persisted row's.
- const tabAgent = this.tabAgents.get(tabId);
- let keyId = opts.keyId ?? tabAgent?.keyId ?? undefined;
- let modelId = opts.modelId ?? tabAgent?.modelId ?? undefined;
- const agentModels = opts.agentModels ?? tabAgent?.agentModels;
- if (!keyId || !modelId) {
- const row = getTab(tabId);
- if (row) {
- keyId = keyId ?? row.keyId ?? undefined;
- modelId = modelId ?? row.modelId ?? undefined;
- }
- }
-
- this.processMessage(
- tabId,
- message,
- keyId,
- modelId,
- opts.reasoningEffort,
- opts.workingDirectory,
- agentModels,
- opts.content,
- ).catch((err) => {
- console.error(`[dispatch] deliverMessage processMessage error for tab ${tabId}:`, err);
- });
- return { status: "started" };
- }
-
- async processMessage(
- tabId: string,
- message: string,
- keyId?: string,
- modelId?: string,
- reasoningEffort?: ReasoningEffort,
- workingDirectory?: string,
- agentModels?: AgentModelEntry[],
- content?: UserContentPart[],
- ): Promise<void> {
- const tabAgent = this._getOrCreateTabAgent(tabId);
-
- // Apply working directory override from frontend if provided
- if (workingDirectory !== undefined) {
- const prevDir = tabAgent.workingDirectoryOverride;
- tabAgent.workingDirectoryOverride = workingDirectory || undefined;
- // Invalidate cached agent if working directory changed
- if (prevDir !== tabAgent.workingDirectoryOverride) {
- tabAgent.agent = null;
- }
- }
- tabAgent.abortController = new AbortController();
- tabAgent.status = "running";
- this.messageCount += 1;
-
- // Persist the user message as a chunk row (once, before any fallback
- // retry). The whole turn — this user message plus the assistant's
- // chunk rows — shares one `turn_id`.
- const turnId = crypto.randomUUID();
- tabAgent.currentTurnId = turnId;
- // Announce the turn so the frontend can tag its live chunks with this
- // turn_id (stable render keys → flicker-free reconcile when the turn
- // seals). Emitted before any content delta.
- this.emit({ type: "turn-start", turnId }, tabId);
- appendChunks(tabId, explodeUserText(turnId, message));
-
- // Store agent models on the tab if provided (defines fallback order)
- if (agentModels) {
- tabAgent.agentModels = agentModels;
- }
-
- // Build the fallback sequence: the agent's models list in order, or a single manual entry
- const fallbackSequence = this.buildFallbackSequence(tabAgent, keyId, modelId);
- const maxFallbackAttempts = fallbackSequence.length;
-
- let processError: string | null = null;
- let allOutput = "";
- let currentKeyId: string | undefined;
- let currentModelId: string | undefined;
-
- for (let fallbackIdx = 0; fallbackIdx < maxFallbackAttempts; fallbackIdx++) {
- const entry = fallbackSequence[fallbackIdx];
- if (!entry) break; // unreachable: loop bound guarantees defined, satisfies TS
- // Convert empty strings (used when caller omitted keyId/modelId in
- // manual mode) to undefined so `getOrCreateAgentForTab` falls back
- // to the tabAgent's stored defaults via the `?? tabAgent.keyId` chain.
- currentKeyId = entry.key_id || undefined;
- currentModelId = entry.model_id || undefined;
- // Effort precedence: per-model (agent definition) → per-tab selector
- // (the `reasoningEffort` arg) → the Agent's own DEFAULT_REASONING_EFFORT
- // floor (applied inside `agent.run`).
- const effortForEntry = entry.effort ?? reasoningEffort;
- allOutput = "";
-
- // Single ordered chunk list accumulating this attempt's assistant
- // turn (text / thinking / tool-batch / error / system), folded from
- // the stream via the shared `appendEventToChunks` helper.
- const chunks: Chunk[] = [];
- // Per-attempt usage accumulator. Reset each fallback attempt so a
- // superseded (rate-limited) attempt's usage is discarded alongside its
- // `chunks`. One `usage` event → one UsageData row.
- const usageRows: UsageData[] = [];
- const assistantId = crypto.randomUUID();
- let assistantPersisted = false;
- tabAgent.currentChunks = chunks;
- tabAgent.currentAssistantId = assistantId;
-
- // Write-on-seal: explode the accumulated turn into flat chunk rows
- // ONCE, when the turn settles. `explodeTurn` splits each step's
- // `tool-batch` into separate `tool_call` + `tool_result` rows and
- // tags every row with `turn_id` + derived `step`.
- const flushAssistant = (): void => {
- if (assistantPersisted) return;
- // Append usage as extra drafts in the SAME appendChunks call as the
- // turn's content rows: one atomic write, one fsync, contiguous seqs.
- // Usage rows are an invisible side channel (excluded from
- // getChunksForTab); `step` is cosmetic for usage (never grouped).
- const drafts = explodeTurn(turnId, chunks);
- for (const u of usageRows) {
- drafts.push({ turnId, step: 0, role: "assistant", type: "usage", data: u });
- }
- if (drafts.length === 0) return;
- appendChunks(tabId, drafts);
- assistantPersisted = true;
- };
-
- let attemptError: string | null = null;
-
- try {
- const agent = await this.getOrCreateAgentForTab(tabId, currentKeyId, currentModelId);
-
- // Ensure tab exists in DB (frontend may have failed to create it)
- try {
- const { getDatabase } = await import("@dispatch/core");
- const db = getDatabase();
- const exists = db.query("SELECT 1 FROM tabs WHERE id = $id").get({ $id: tabId });
- if (!exists) {
- const { createTab } = await import("@dispatch/core");
- createTab(tabId, "New Tab", {
- keyId: currentKeyId ?? null,
- modelId: currentModelId ?? null,
- });
- }
- } catch {
- // Best-effort — if this fails, chunk persistence will throw and we'll catch it below
- }
-
- for await (const event of agent.run(message, {
- ...(effortForEntry ? { reasoningEffort: effortForEntry } : {}),
- abortSignal: tabAgent.abortController?.signal,
- ...(content ? { content } : {}),
- })) {
- // Stop processing if the tab was aborted (closed/stopped).
- // stopTab() already injected a `cancelled` system chunk into
- // `chunks` before flipping the abort flag, so we just need
- // to flush and exit.
- if (tabAgent.abortController?.signal.aborted) break;
-
- if (event.type === "error") {
- attemptError = event.error;
- // Record the error as a chunk so it's part of the
- // persisted turn history.
- appendEventToChunks(chunks, event);
- break;
- }
-
- if (event.type === "status") {
- tabAgent.status = event.status;
- }
- this.emit(event, tabId);
-
- // For diagnostics / child agent result harvesting, keep a
- // flat string copy of plain text output.
- if (event.type === "text-delta") {
- allOutput += event.delta;
- }
-
- // Capture per-step usage as a side-channel row to persist with the
- // turn (one row per `usage` event). The live `this.emit(event)`
- // above still drives in-session accumulation; this is the reload-
- // persistence path. `appendEventToChunks` intentionally ignores
- // `usage`, so it never becomes message content.
- if (event.type === "usage") {
- usageRows.push({ ...event.usage });
- }
-
- // Route every content-bearing event through the shared helper.
- // `appendEventToChunks` ignores lifecycle events (status / done
- // / task-list-update / tab-created / message-* / etc), so it's
- // safe to call unconditionally. Persistence happens once, after
- // the loop, so we never write a partial turn that a fallback
- // retry would then duplicate.
- appendEventToChunks(chunks, event);
- }
- } catch (err) {
- console.error(`[dispatch] processMessage error for tab ${tabId}:`, err);
- attemptError = err instanceof Error ? err.message : String(err);
- }
-
- // Decide whether a fallback retry will supersede this attempt.
- const isRetryable =
- attemptError !== null &&
- (attemptError.includes("status=429") ||
- attemptError.toLowerCase().includes("rate limit") ||
- attemptError.toLowerCase().includes("rate_limit") ||
- attemptError.toLowerCase().includes("usage limit") ||
- attemptError.toLowerCase().includes("exhausted"));
- const nextEntry = fallbackSequence[fallbackIdx + 1];
- const willRetry = Boolean(isRetryable && this.modelRegistry && tabAgent.keyId && nextEntry);
-
- // Persist this attempt's turn — unless a retry will replace it, in
- // which case the partial (and its error chunk) is discarded so the
- // next attempt's chunks don't merge with a failed one. On success,
- // abort, or a final error, the turn is flushed exactly once.
- if (!willRetry) {
- flushAssistant();
- }
- tabAgent.currentChunks = null;
- tabAgent.currentAssistantId = null;
-
- // No error — success
- if (!attemptError) {
- processError = null;
- break;
- }
-
- if (willRetry && nextEntry && tabAgent.keyId) {
- this.modelRegistry?.markKeyExhausted(tabAgent.keyId, attemptError);
- const fallbackMsg =
- `Key "${tabAgent.keyId}" rate limited. ` +
- `Falling back to "${nextEntry.key_id}" (model: ${nextEntry.model_id})...`;
- console.warn(`[dispatch] ${fallbackMsg}`);
- // Persist the notice + model-change as standalone system chunk
- // rows (no turn in flight now — currentChunks was just cleared).
- this.emit({ type: "notice", message: fallbackMsg }, tabId);
- this.routeSystemEventToTab(tabId, "notice", fallbackMsg);
- this.emit(
- { type: "model-changed", keyId: nextEntry.key_id, modelId: nextEntry.model_id },
- tabId,
- );
- this.routeSystemEventToTab(
- tabId,
- "model-changed",
- `Switched to ${nextEntry.model_id} (${nextEntry.key_id})`,
- );
- tabAgent.agent = null;
- continue;
- }
-
- // All fallbacks exhausted or non-retryable error
- processError = attemptError;
- tabAgent.status = "error";
- this.emit({ type: "error", error: attemptError }, tabId);
- this.emit({ type: "status", status: "error" }, tabId);
- break;
- }
- // Turn fully settled and its chunks are now persisted (flushAssistant ran
- // above). Signal the frontend that the turn's rows — with real seqs — are
- // durable so it can fold its live representation into the sealed log.
- // Emitted AFTER status:idle/error (which fire before the DB write).
- // Carry the authoritative usage aggregate (read AFTER the usage rows were
- // persisted) so the frontend reconciles its live cacheStats to the DB truth
- // — self-healing the live overshoot from a discarded rate-limited attempt.
- let usageStats: UsageStats | null = null;
- try {
- usageStats = getUsageStatsForTab(tabId);
- } catch {
- // DB read failed — omit reconciliation rather than crash the turn.
- }
- this.emit({ type: "turn-sealed", turnId, usageStats }, tabId);
-
- // Turn fully settled — clear the shared turn id.
- tabAgent.currentTurnId = null;
-
- // Resolve completion promise for child agents
- if (processError === null) {
- tabAgent.finalOutput = allOutput;
- tabAgent.completionResolve?.({ status: "done", result: allOutput || "(no output)" });
- } else {
- tabAgent.completionResolve?.({ status: "error", error: processError });
- }
-
- // The turn has fully settled. If messages piled up on the queue during it
- // and were NOT injected as a mid-turn interrupt (they arrived after the
- // last tool call, or this turn had no tool calls), kick off a fresh turn
- // to answer them instead of letting them sit unanswered — the queue is
- // consumed, not just appended. Only on a clean finish: a turn the user
- // explicitly stopped, or one that errored out, leaves its queue intact
- // for the next deliberate send (see continueFromQueue).
- if (processError === null) {
- this.continueFromQueue(tabId);
- }
- }
-
- /**
- * Start a new turn for any messages that accumulated on `tabId`'s queue
- * during the turn that just finished. This is what makes a queued message
- * (from a user OR another agent via send_to_tab) actually get a response
- * after the agent's current turn ends, rather than waiting forever.
- *
- * Loop safety: a queued-then-continued turn draws from the SAME
- * `autoWakeBudget` that bounds agent-to-agent wakes. Every human-originated
- * message refills that budget when it is delivered (see deliverMessage), so
- * human conversations are never throttled; only a runaway agent<->agent
- * chain (A queues B, B queues A, ...) is capped. When the budget is spent
- * the messages stay queued and a notice is emitted; the next human message
- * refills the budget and starts their turn.
- */
- private continueFromQueue(tabId: string): void {
- const tabAgent = this.tabAgents.get(tabId);
- if (!tabAgent) return;
- if (tabAgent.messageQueue.length === 0) return;
- // Never auto-continue a turn the user stopped or one that errored.
- if (tabAgent.status === "error") return;
- if (tabAgent.abortController?.signal.aborted) return;
-
- if (tabAgent.autoWakeBudget <= 0) {
- // Budget spent — hold the queued messages (don't drop them) until a
- // human message refills the budget. Prevents unbounded agent loops.
- const notice =
- `Automatic continuation limit reached for this tab ` +
- `(${MAX_AGENT_AUTO_WAKES} consecutive turns). Queued messages are held ` +
- `until you send a message here.`;
- this.emit({ type: "notice", message: notice }, tabId);
- this.routeSystemEventToTab(tabId, "notice", notice);
- return;
- }
- tabAgent.autoWakeBudget -= 1;
-
- // Drain the queue as a "continuation" so the frontend folds the pending
- // queued bubbles into this NEW turn's initiating user row (rather than
- // into a running turn's tool result, which is the "interrupt" case).
- const drained = this.dequeueMessages(tabId, "continuation");
- if (drained.length === 0) return;
- const message = drained.map((m) => m.message).join("\n---\n");
-
- // Reuse the tab's resolved key/model/fallback chain — the continuation is
- // the same conversation, just a new turn. Fire-and-forget: if more
- // messages arrive during it, its own tail will continue the chain.
- this.processMessage(
- tabId,
- message,
- tabAgent.keyId ?? undefined,
- tabAgent.modelId ?? undefined,
- undefined,
- undefined,
- tabAgent.agentModels,
- ).catch((err) => {
- console.error(`[dispatch] continueFromQueue processMessage error for tab ${tabId}:`, err);
- });
- }
-
- private buildFallbackSequence(
- tabAgent: TabAgent,
- keyId?: string,
- modelId?: string,
- ): AgentModelEntry[] {
- // Agent mode: use the agent's configured fallback hierarchy in strict order
- const models = tabAgent.agentModels;
- if (models && models.length > 0) {
- const startIdx = models.findIndex((m) => m.key_id === keyId && m.model_id === modelId);
- return startIdx >= 0 ? models.slice(startIdx) : models;
- }
- // Manual mode: no fallback — just the selected key/model pair.
- // Always return at least one entry so `processMessage` runs the agent
- // once (empty strings let `getOrCreateAgentForTab` fall back to the
- // tabAgent's stored defaults or environment-driven config).
- return [{ key_id: keyId ?? "", model_id: modelId ?? "" }];
- }
-
- queueMessage(tabId: string, message: string, clientId?: string): { messageId: string } {
- const tabAgent = this.tabAgents.get(tabId);
- if (!tabAgent) throw new Error("Tab not found");
- const id = clientId || crypto.randomUUID();
- const queued: QueuedMessage = { id, message, timestamp: Date.now() };
- tabAgent.messageQueue.push(queued);
- // Wake up any blocking tools waiting for queue
- for (const listener of tabAgent.queueListeners) {
- listener();
- }
- tabAgent.queueListeners = [];
- this.emit({ type: "message-queued", tabId, messageId: id, message }, tabId);
- return { messageId: id };
- }
-
- cancelQueuedMessage(tabId: string, messageId: string): boolean {
- const tabAgent = this.tabAgents.get(tabId);
- if (!tabAgent) return false;
- const idx = tabAgent.messageQueue.findIndex((m) => m.id === messageId);
- if (idx === -1) return false;
- tabAgent.messageQueue.splice(idx, 1);
- this.emit({ type: "message-cancelled", tabId, messageId }, tabId);
- return true;
- }
-
- dequeueMessages(
- tabId: string,
- reason: "interrupt" | "continuation" = "interrupt",
- ): QueuedMessage[] {
- const tabAgent = this.tabAgents.get(tabId);
- if (!tabAgent) return [];
- const messages = [...tabAgent.messageQueue];
- tabAgent.messageQueue = [];
- if (messages.length > 0) {
- this.emit(
- { type: "message-consumed", tabId, messageIds: messages.map((m) => m.id), reason },
- tabId,
- );
- }
- return messages;
- }
-
- waitForQueuedMessage(tabId: string): { promise: Promise<void>; cancel: () => void } {
- const tabAgent = this.tabAgents.get(tabId);
- if (!tabAgent) return { promise: Promise.resolve(), cancel: () => {} };
- if (tabAgent.messageQueue.length > 0) return { promise: Promise.resolve(), cancel: () => {} };
-
- let listener: (() => void) | null = null;
- const promise = new Promise<void>((resolve) => {
- listener = resolve;
- tabAgent.queueListeners.push(resolve);
- });
- const cancel = () => {
- if (listener) {
- tabAgent.queueListeners = tabAgent.queueListeners.filter((l) => l !== listener);
- listener = null;
- }
- };
- return { promise, cancel };
- }
-
- destroy(): void {
- this.configWatcher?.close();
- this.skillsWatcher?.close();
- for (const watcher of this.lspDirWatchers.values()) watcher.close();
- this.lspDirWatchers.clear();
- // Shut down all long-lived LSP server processes. Fire-and-forget: the
- // promise is detached so `destroy()` stays synchronous (matching its
- // existing contract), but every client gets `shutdown()` called.
- void this.lspManager.shutdownAll();
- }
-}
diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts
deleted file mode 100644
index 72188ff..0000000
--- a/packages/api/src/app.ts
+++ /dev/null
@@ -1,278 +0,0 @@
-import {
- type AgentModelEntry,
- getTab,
- isReasoningEffort,
- NotificationDispatcher,
- type UserContentPart,
- validateUserContent,
-} from "@dispatch/core";
-import { Hono } from "hono";
-import { cors } from "hono/cors";
-import { AgentManager } from "./agent-manager.js";
-import { PermissionManager } from "./permission-manager.js";
-import { agentsRoutes } from "./routes/agents.js";
-import { configRoutes } from "./routes/config.js";
-import { modelsRoutes, startWakeScheduler } from "./routes/models.js";
-import { notificationsRoutes } from "./routes/notifications.js";
-import { skillsRoutes } from "./routes/skills.js";
-import { tabsRoutes } from "./routes/tabs.js";
-
-/**
- * Validate and normalise the `agentModels` fallback chain coming from the
- * frontend. Each entry must carry string `key_id`/`model_id`; an `effort` is
- * kept only when it's a recognised level (otherwise dropped so the per-tab /
- * default effort applies). Returns `undefined` when the input isn't an array.
- */
-function sanitizeAgentModels(raw: unknown): AgentModelEntry[] | undefined {
- if (!Array.isArray(raw)) return undefined;
- const out: AgentModelEntry[] = [];
- for (const m of raw) {
- if (!m || typeof m !== "object") continue;
- const entry = m as Record<string, unknown>;
- if (typeof entry.key_id !== "string" || typeof entry.model_id !== "string") continue;
- out.push({
- key_id: entry.key_id,
- model_id: entry.model_id,
- ...(isReasoningEffort(entry.effort) ? { effort: entry.effort } : {}),
- });
- }
- return out;
-}
-
-/**
- * Validate and normalise the optional multimodal `content` array from the
- * `/chat` body. Each entry is either a `{ type: "text", text }` part or a
- * `{ type: "attachment", mediaType, data, name? }` part (base64 payload).
- * Returns `undefined` when the input isn't a non-empty array or contains no
- * attachment (so the plain-string path is taken — byte-identical to before).
- * Shape only: SIZE/TYPE limits are enforced separately by `validateUserContent`.
- */
-function sanitizeUserContent(raw: unknown): UserContentPart[] | undefined {
- if (!Array.isArray(raw) || raw.length === 0) return undefined;
- const out: UserContentPart[] = [];
- let hasAttachment = false;
- for (const p of raw) {
- if (!p || typeof p !== "object") continue;
- const part = p as Record<string, unknown>;
- if (part.type === "text") {
- if (typeof part.text === "string") out.push({ type: "text", text: part.text });
- continue;
- }
- if (part.type === "attachment") {
- if (typeof part.mediaType !== "string" || typeof part.data !== "string") continue;
- hasAttachment = true;
- out.push({
- type: "attachment",
- mediaType: part.mediaType,
- data: part.data,
- ...(typeof part.name === "string" ? { name: part.name } : {}),
- });
- }
- }
- // No attachment → let the plain-text path handle it (avoids needlessly
- // switching the model message to array content for a text-only turn).
- return hasAttachment ? out : undefined;
-}
-
-export const permissionManager = new PermissionManager();
-export const agentManager = new AgentManager(permissionManager);
-
-// ntfy.sh push notifications. The dispatcher reads its config from the
-// `settings` table on every send, so config changes apply immediately —
-// no restart, no re-attach needed.
-export const notificationDispatcher = new NotificationDispatcher({
- getTabTitle: (tabId) => {
- try {
- return getTab(tabId)?.title ?? null;
- } catch {
- return null;
- }
- },
- getTabParentId: (tabId) => {
- try {
- // `undefined` when the lookup fails (tab not found / DB unavailable)
- // so the dispatcher falls back to "treat as top-level" rather than
- // silently dropping notifications.
- const row = getTab(tabId);
- return row ? row.parentTabId : undefined;
- } catch {
- return undefined;
- }
- },
-});
-notificationDispatcher.attachToAgentManager(agentManager);
-notificationDispatcher.attachToPermissionManager(permissionManager);
-
-export const app = new Hono();
-
-app.use(
- "*",
- cors({
- origin: (origin) => origin || "*",
- credentials: true,
- allowHeaders: ["Content-Type", "Authorization"],
- allowMethods: ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"],
- }),
-);
-
-app.get("/health", (c) => {
- return c.json({ ok: true });
-});
-
-app.get("/status", (c) => {
- return c.json({
- status: agentManager.getStatus(),
- messageCount: agentManager.getMessageCount(),
- statuses: agentManager.getAllStatuses(),
- });
-});
-
-app.post("/chat", async (c) => {
- const body = await c.req.json<{
- tabId?: unknown;
- message?: unknown;
- content?: unknown;
- keyId?: unknown;
- modelId?: unknown;
- agentModels?: unknown;
- reasoningEffort?: unknown;
- workingDirectory?: unknown;
- queueId?: unknown;
- }>();
- const { tabId, message } = body;
-
- if (typeof tabId !== "string" || tabId.trim() === "") {
- return c.json({ error: "tabId must be a non-empty string" }, 400);
- }
-
- if (typeof message !== "string" || message.trim() === "") {
- return c.json({ error: "message must be a non-empty string" }, 400);
- }
-
- const keyId = typeof body.keyId === "string" ? body.keyId : undefined;
- const modelId = typeof body.modelId === "string" ? body.modelId : undefined;
- const agentModels = sanitizeAgentModels(body.agentModels);
- const workingDirectory =
- typeof body.workingDirectory === "string" ? body.workingDirectory : undefined;
- const queueId = typeof body.queueId === "string" ? body.queueId : undefined;
- const reasoningEffort = isReasoningEffort(body.reasoningEffort)
- ? body.reasoningEffort
- : undefined;
-
- // Optional multimodal content (image/pdf attachments). When present, the
- // attachments are EPHEMERAL — forwarded to the model for this turn only and
- // never persisted (the chunk log keeps just `message`, which the frontend
- // has already projected to text with `[image]`/`[pdf]` markers).
- const content = sanitizeUserContent(body.content);
- if (content) {
- // Enforce size/type/count ceilings server-side (defence in depth; the
- // frontend also enforces them at paste time). Reject the whole request
- // so no tokens are spent on an over-limit payload.
- const validation = validateUserContent(content);
- if (!validation.ok) {
- return c.json({ error: "invalid attachments", details: validation.errors }, 400);
- }
- // Attachments only attach to a FRESH turn. If the tab is mid-turn the
- // message would queue (text-only machinery), silently dropping the
- // images. Reject clearly instead so the user can retry once idle.
- if (agentManager.getTabStatus(tabId) === "running") {
- return c.json(
- { error: "cannot attach images while the agent is generating; wait for it to finish" },
- 409,
- );
- }
- }
-
- // Single routing decision (queue if busy, new turn if idle) shared with the
- // `send_to_tab` tool via `AgentManager.deliverMessage`. Non-blocking — a
- // started turn runs in the background.
- const outcome = agentManager.deliverMessage(tabId, message, {
- ...(keyId ? { keyId } : {}),
- ...(modelId ? { modelId } : {}),
- ...(agentModels ? { agentModels } : {}),
- ...(reasoningEffort ? { reasoningEffort } : {}),
- ...(workingDirectory !== undefined ? { workingDirectory } : {}),
- ...(queueId ? { queueId } : {}),
- ...(content ? { content } : {}),
- });
-
- if (outcome.status === "queued") {
- return c.json({ status: "queued", messageId: outcome.messageId });
- }
- return c.json({ status: "ok" });
-});
-
-app.route("/config", configRoutes);
-
-app.post("/chat/cancel", async (c) => {
- const body = await c.req.json();
- if (typeof body.tabId !== "string" || typeof body.messageId !== "string") {
- return c.json({ error: "tabId and messageId are required strings" }, 400);
- }
- const tabId = body.tabId;
- const messageId = body.messageId;
- const cancelled = agentManager.cancelQueuedMessage(tabId, messageId);
- return c.json({ success: cancelled });
-});
-
-app.post("/chat/stop", async (c) => {
- const body = await c.req.json();
- if (typeof body.tabId !== "string") {
- return c.json({ error: "tabId is required" }, 400);
- }
- agentManager.stopTab(body.tabId);
- return c.json({ success: true });
-});
-
-// Prompt-cache WARMING (see AgentManager.warmCacheForTab / Agent.warmCache).
-//
-// Replays the tab's exact cached prefix + one trivial throwaway turn so the
-// provider's ~5-min prompt-cache TTL is refreshed while the tab sits idle.
-// The frontend's cache-warming timer drives this every ~4 minutes. The
-// warming request is NEVER persisted, NEVER emitted, and NEVER folded into the
-// real usage aggregate — we return ONLY its `usage` so the UI can show a
-// warming-specific "last request" cache rate without polluting the real
-// Cache Rate metric. Returns 409 when the tab is mid-turn (caller also gates).
-app.post("/chat/warm", async (c) => {
- const body = await c.req.json<{
- tabId?: unknown;
- keyId?: unknown;
- modelId?: unknown;
- agentModels?: unknown;
- reasoningEffort?: unknown;
- }>();
- const { tabId } = body;
- if (typeof tabId !== "string" || tabId.trim() === "") {
- return c.json({ error: "tabId must be a non-empty string" }, 400);
- }
- const keyId = typeof body.keyId === "string" ? body.keyId : undefined;
- const modelId = typeof body.modelId === "string" ? body.modelId : undefined;
- const agentModels = sanitizeAgentModels(body.agentModels);
- // Same effort the real turn would use — a message-cache key, so warming must
- // match it to refresh the SAME bucket the next real message reads.
- const reasoningEffort = isReasoningEffort(body.reasoningEffort)
- ? body.reasoningEffort
- : undefined;
-
- const result = await agentManager.warmCacheForTab(tabId, {
- ...(keyId ? { keyId } : {}),
- ...(modelId ? { modelId } : {}),
- ...(agentModels ? { agentModels } : {}),
- ...(reasoningEffort ? { reasoningEffort } : {}),
- });
- if (!result.ok) {
- // "tab is generating" is an expected race (not a server fault) → 409.
- const status = result.error === "tab is generating" ? 409 : 500;
- return c.json({ error: result.error }, status);
- }
- return c.json({ usage: result.usage });
-});
-
-app.route("/skills", skillsRoutes);
-app.route("/models", modelsRoutes);
-app.route("/tabs", tabsRoutes);
-app.route("/agents", agentsRoutes);
-app.route("/notifications", notificationsRoutes);
-
-// Start the wake scheduler on boot (restores persisted schedule)
-startWakeScheduler();
diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts
deleted file mode 100644
index 5615e08..0000000
--- a/packages/api/src/index.ts
+++ /dev/null
@@ -1,127 +0,0 @@
-import type { PermissionReply } from "@dispatch/core";
-import { createBunWebSocket } from "hono/bun";
-import { agentManager, app, permissionManager } from "./app.js";
-
-const { upgradeWebSocket, websocket } = createBunWebSocket();
-
-let clientIdCounter = 0;
-
-app.get(
- "/ws",
- upgradeWebSocket((_c) => {
- const clientId = String(++clientIdCounter);
-
- return {
- onOpen(_event, ws) {
- // Send current statuses immediately
- ws.send(JSON.stringify({ type: "statuses", statuses: agentManager.getAllStatuses() }));
-
- // Send any pending permission prompts
- const pending = permissionManager.getPending();
- if (pending.length > 0) {
- ws.send(JSON.stringify({ type: "permission-prompt", pending }));
- }
-
- const unsubscribe = agentManager.onEvent((event) => {
- ws.send(JSON.stringify(event));
- });
-
- permissionManager.registerClient(clientId, (data) => {
- ws.send(JSON.stringify(data));
- });
-
- // Store cleanup on the raw socket
- (ws as unknown as { _unsub?: () => void; _clientId?: string })._unsub = unsubscribe;
- (ws as unknown as { _unsub?: () => void; _clientId?: string })._clientId = clientId;
- },
- onMessage(event, _ws) {
- try {
- const message = JSON.parse(String(event.data)) as {
- type?: string;
- id?: string;
- reply?: string;
- };
- if (
- message.type === "permission-reply" &&
- typeof message.id === "string" &&
- typeof message.reply === "string"
- ) {
- const validReplies: PermissionReply[] = ["once", "always", "reject"];
- if (validReplies.includes(message.reply as PermissionReply)) {
- permissionManager.reply(message.id, message.reply as PermissionReply);
- }
- }
- } catch {
- // ignore malformed messages
- }
- },
- onClose(_event, ws) {
- const raw = ws as unknown as { _unsub?: () => void; _clientId?: string };
- if (raw._unsub) {
- raw._unsub();
- }
- if (raw._clientId) {
- permissionManager.unregisterClient(raw._clientId);
- }
- },
- };
- }),
-);
-
-export { app };
-
-// Starting port (overridable via PORT). When the port is already in use we
-// bump up one at a time (3000 → 3001 → 3002, …) until we find a free one, so
-// multiple dispatch instances (e.g. testing several features at once) can
-// coexist without manually juggling ports. The frontend defaults to :3000 —
-// point it at the chosen port via the in-app API-URL field / VITE_API_URL
-// when a bump happens.
-const START_PORT = Number(process.env.PORT) || 3000;
-
-/**
- * Bind the server to `START_PORT`, incrementing by one on EADDRINUSE until a
- * free port is found (up to the maximum valid TCP port, 65535). Bun's
- * `Bun.serve` throws synchronously when the port is taken, so we can catch and
- * retry. Returns the live server (whose `.port` reflects the port actually
- * bound).
- */
-function serveWithPortFallback() {
- let lastError: unknown;
- for (let port = START_PORT; port <= 65535; port++) {
- try {
- const server = Bun.serve({
- port,
- idleTimeout: 60,
- fetch: app.fetch,
- websocket,
- });
- if (port !== START_PORT) {
- console.warn(
- `dispatch: port ${START_PORT} in use — bound to ${port} instead. ` +
- `Set the frontend's API URL to http://localhost:${port}.`,
- );
- }
- console.log(`dispatch: API listening on http://localhost:${server.port}`);
- return server;
- } catch (err) {
- const code = (err as NodeJS.ErrnoException)?.code;
- if (code === "EADDRINUSE") {
- lastError = err;
- continue;
- }
- throw err;
- }
- }
- console.error(
- `dispatch: no free port at or above ${START_PORT}. ` +
- `Free one up or set PORT to an open port.`,
- );
- throw lastError ?? new Error(`No free port at or above ${START_PORT}`);
-}
-
-// Only start the server when run as the entry point — importing this module
-// (e.g. for `app`) must not bind a port. This preserves the prior
-// default-export behavior where Bun served only the entry file.
-if (import.meta.main) {
- serveWithPortFallback();
-}
diff --git a/packages/api/src/permission-manager.ts b/packages/api/src/permission-manager.ts
deleted file mode 100644
index 3a24d03..0000000
--- a/packages/api/src/permission-manager.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-import {
- type PermissionReply,
- type PermissionRequest,
- PermissionService,
- type Ruleset,
-} from "@dispatch/core";
-
-/**
- * Listener fired exactly once per newly-created pending prompt. Used by
- * the notification dispatcher so that a permission request triggers a
- * push notification on the user's phone (without re-firing every time
- * the pending list mutates for an unrelated reason).
- */
-export type PromptAddedListener = (prompt: {
- id: string;
- permission: string;
- description: string;
- metadata: Record<string, unknown>;
-}) => void;
-
-export class PermissionManager {
- private service = new PermissionService();
- private wsClients: Map<string, (data: unknown) => void> = new Map();
- private promptAddedListeners: Set<PromptAddedListener> = new Set();
- /** Ids that have already been broadcast as "added" — guards against re-emits. */
- private announcedPromptIds: Set<string> = new Set();
-
- registerClient(id: string, send: (data: unknown) => void): void {
- this.wsClients.set(id, send);
- }
-
- unregisterClient(id: string): void {
- this.wsClients.delete(id);
- }
-
- private broadcastPending(pending: Array<{ id: string; request: PermissionRequest }>): void {
- const message = {
- type: "permission-prompt",
- pending: pending.map((p) => ({ id: p.id, ...p.request })),
- };
- for (const send of this.wsClients.values()) {
- send(message);
- }
-
- // Detect newly-added prompts (ids present now that weren't before) and
- // fire `promptAddedListeners` once for each. Resolved/rejected ids are
- // pruned from `announcedPromptIds` so a future prompt that reuses an
- // id (theoretical, given the monotonic counter) would still notify.
- const currentIds = new Set(pending.map((p) => p.id));
- for (const id of this.announcedPromptIds) {
- if (!currentIds.has(id)) this.announcedPromptIds.delete(id);
- }
- for (const p of pending) {
- if (this.announcedPromptIds.has(p.id)) continue;
- this.announcedPromptIds.add(p.id);
- for (const listener of this.promptAddedListeners) {
- try {
- listener({
- id: p.id,
- permission: p.request.permission,
- description: p.request.description,
- metadata: p.request.metadata,
- });
- } catch (err) {
- console.warn(
- `[permission] promptAdded listener threw: ${err instanceof Error ? err.message : String(err)}`,
- );
- }
- }
- }
- }
-
- async ask(request: PermissionRequest, rulesets: Ruleset[] = []): Promise<PermissionReply> {
- const promise = this.service.ask(request, rulesets);
- this.broadcastPending(this.service.getPending());
- return promise;
- }
-
- reply(id: string, reply: PermissionReply): void {
- this.service.reply(id, reply);
- this.broadcastPending(this.service.getPending());
- }
-
- getPending(): Array<{ id: string; request: PermissionRequest }> {
- return this.service.getPending();
- }
-
- getService(): PermissionService {
- return this.service;
- }
-
- /**
- * Subscribe to "a new prompt is now pending" events. Fires once per
- * unique prompt id, even if `broadcastPending` is called repeatedly
- * for unrelated mutations. Returns an unsubscribe function.
- */
- onPromptAdded(listener: PromptAddedListener): () => void {
- this.promptAddedListeners.add(listener);
- return () => {
- this.promptAddedListeners.delete(listener);
- };
- }
-}
diff --git a/packages/api/src/routes/agents.ts b/packages/api/src/routes/agents.ts
deleted file mode 100644
index 10ca714..0000000
--- a/packages/api/src/routes/agents.ts
+++ /dev/null
@@ -1,126 +0,0 @@
-import * as fs from "node:fs";
-import * as os from "node:os";
-import * as path from "node:path";
-import type { AgentDefinition } from "@dispatch/core";
-import {
- deleteAgent,
- getAgentDirs,
- isReasoningEffort,
- loadAgents,
- saveAgent,
-} from "@dispatch/core";
-import { Hono } from "hono";
-
-const SAFE_SLUG_RE = /^[a-zA-Z0-9_-]+$/;
-
-function isValidSlug(slug: string): boolean {
- return SAFE_SLUG_RE.test(slug) && slug.length > 0 && slug.length <= 100;
-}
-
-const agentsRoutes = new Hono();
-
-// GET /agents — list all agents (global + project-scoped)
-// Query param: ?projectDir=... (optional, the working directory)
-agentsRoutes.get("/", (c) => {
- const projectDir = c.req.query("projectDir") || process.env.DISPATCH_WORKING_DIR || undefined;
- const agents = loadAgents(projectDir);
- const dirs = getAgentDirs(projectDir);
- return c.json({ agents, dirs });
-});
-
-// GET /agents/dirs — list available agent directories
-agentsRoutes.get("/dirs", (c) => {
- const projectDir = c.req.query("projectDir") || process.env.DISPATCH_WORKING_DIR || undefined;
- const dirs = getAgentDirs(projectDir);
- return c.json({ dirs });
-});
-
-// POST /agents — create or update an agent
-agentsRoutes.post("/", async (c) => {
- try {
- const body = await c.req.json<AgentDefinition>();
- // Validate required fields
- if (!body.name || !body.slug || !body.scope) {
- return c.json({ error: "name, slug, and scope are required" }, 400);
- }
- if (!isValidSlug(body.slug)) {
- return c.json(
- { error: "Invalid slug: must be alphanumeric with hyphens/underscores only" },
- 400,
- );
- }
- if (body.scope !== "global" && body.scope.includes("..")) {
- return c.json({ error: "Invalid scope" }, 400);
- }
- // Ensure arrays exist
- const agent: AgentDefinition = {
- name: body.name,
- description: body.description || "",
- skills: body.skills || [],
- tools: body.tools || [],
- models: (body.models || []).map((m) => ({
- key_id: m.key_id,
- model_id: m.model_id,
- // Keep `effort` only when it's a recognised level; drop anything else.
- ...(isReasoningEffort(m.effort) ? { effort: m.effort } : {}),
- })),
- scope: body.scope,
- slug: body.slug,
- ...(body.cwd ? { cwd: body.cwd } : {}),
- ...(body.is_subagent ? { is_subagent: true } : {}),
- };
- saveAgent(agent);
- return c.json({ ok: true, agent });
- } catch (err) {
- return c.json({ error: err instanceof Error ? err.message : "Failed to save agent" }, 500);
- }
-});
-
-// DELETE /agents/:slug — delete an agent
-// Query param: ?scope=... (required: "global" or directory path)
-agentsRoutes.delete("/:slug", (c) => {
- const slug = c.req.param("slug");
- const scope = c.req.query("scope");
- if (!scope) {
- return c.json({ error: "scope query param is required" }, 400);
- }
- if (!isValidSlug(slug)) {
- return c.json({ error: "Invalid slug" }, 400);
- }
- if (slug === "default" && scope === "global") {
- return c.json({ error: "Cannot delete the default agent" }, 403);
- }
- if (scope !== "global" && scope.includes("..")) {
- return c.json({ error: "Invalid scope" }, 400);
- }
- const deleted = deleteAgent(slug, scope);
- if (!deleted) {
- return c.json({ error: "Agent not found" }, 404);
- }
- return c.json({ ok: true });
-});
-
-// GET /agents/check-dir?path=... — check if a directory exists
-agentsRoutes.get("/check-dir", (c) => {
- let dirPath = c.req.query("path");
- if (!dirPath) {
- return c.json({ exists: false });
- }
- // Expand ~ to home directory
- if (dirPath === "~" || dirPath.startsWith("~/")) {
- dirPath = path.join(os.homedir(), dirPath.slice(1));
- }
- // Resolve relative paths against the project root
- if (!path.isAbsolute(dirPath)) {
- const projectDir = process.env.DISPATCH_WORKING_DIR || process.cwd();
- dirPath = path.resolve(projectDir, dirPath);
- }
- try {
- const stat = fs.statSync(dirPath);
- return c.json({ exists: stat.isDirectory(), resolved: dirPath });
- } catch {
- return c.json({ exists: false, resolved: dirPath });
- }
-});
-
-export { agentsRoutes };
diff --git a/packages/api/src/routes/config.ts b/packages/api/src/routes/config.ts
deleted file mode 100644
index 65a1e2a..0000000
--- a/packages/api/src/routes/config.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import type { DispatchConfig } from "@dispatch/core";
-import { Hono } from "hono";
-
-let getConfig: () => DispatchConfig = () => ({ permissions: {} });
-
-export function setConfigGetter(getter: () => DispatchConfig): void {
- getConfig = getter;
-}
-
-const configRoutes = new Hono();
-
-configRoutes.get("/", (c) => {
- const config = getConfig();
-
- // Strip env field values from keys for security
- const safeConfig: DispatchConfig = {
- ...config,
- keys: config.keys?.map((key) => ({
- ...key,
- env: "***",
- })),
- };
-
- return c.json({ config: safeConfig });
-});
-
-export { configRoutes };
diff --git a/packages/api/src/routes/models.ts b/packages/api/src/routes/models.ts
deleted file mode 100644
index a1700b1..0000000
--- a/packages/api/src/routes/models.ts
+++ /dev/null
@@ -1,1073 +0,0 @@
-import { randomUUID } from "node:crypto";
-import { readFileSync, writeFileSync } from "node:fs";
-import { homedir } from "node:os";
-import type { ModelRegistry } from "@dispatch/core";
-import {
- ANTHROPIC_MODELS_FALLBACK,
- buildWakeProbeBody,
- type ClaudeAccount,
- fetchAnthropicModels,
- fetchCopilotUsage,
- fetchGoogleUsage,
- fetchOpencodeUsage,
- getAccountUsage,
- getAnthropicHeaders,
- getClaudeAccountsFromDB,
- getDatabase,
- importCredentialsFromFile,
- listApiKeys,
- listStoredCredentials,
- refreshAccountCredentialsAsync,
- resolveApiKey,
- resolveContextLimit,
- resolveModelCapabilities,
- selectHaikuModel,
- setApiKey,
- validateAccountCredentials,
-} from "@dispatch/core";
-import { Hono } from "hono";
-import {
- CLAUDE_RESET_OFFSET_HOURS,
- isProbeSlotMinute,
- nextDailyAfter,
- PROBE_SLOT_MINUTES,
- type ProbeSlotMinute,
- recoverScheduleEntry,
-} from "../wake-scheduler.js";
-
-let getRegistry: () => ModelRegistry | null = () => null;
-let getAccounts: () => ClaudeAccount[] = () => [];
-
-export function setModelsGetter(registryGetter: () => ModelRegistry | null): void {
- getRegistry = registryGetter;
-}
-
-export function setAccountsGetter(getter: () => ClaudeAccount[]): void {
- getAccounts = getter;
-}
-
-/** Load Claude accounts from the database. */
-function resolveClaudeAccounts(): ClaudeAccount[] {
- return getClaudeAccountsFromDB();
-}
-
-export const modelsRoutes = new Hono();
-
-modelsRoutes.get("/", (c) => {
- const registry = getRegistry();
- if (!registry) {
- return c.json({ keys: [] });
- }
-
- const keyStates = registry.getKeys();
-
- const keys = keyStates.map((ks) => ({
- id: ks.definition.id,
- provider: ks.definition.provider,
- status: ks.status,
- lastError: ks.lastError ?? null,
- exhaustedAt: ks.exhaustedAt ?? null,
- }));
-
- return c.json({ keys });
-});
-
-// Fetch available models for a specific provider key.
-modelsRoutes.get("/available", async (c) => {
- const registry = getRegistry();
- if (!registry) {
- return c.json({ error: "no registry configured" }, 500);
- }
-
- const keyId = c.req.query("keyId");
- if (!keyId) {
- return c.json({ error: "keyId query parameter is required" }, 400);
- }
-
- const keyStates = registry.getKeys();
- const key = keyStates.find((ks) => ks.definition.id === keyId);
- if (!key) {
- return c.json({ error: `key not found: ${keyId}` }, 404);
- }
-
- // Anthropic provider: validate credentials and fetch models dynamically
- if (key.definition.provider === "anthropic") {
- const credFile = key.definition.credentials_file;
- const accounts = resolveClaudeAccounts();
- const account =
- accounts.find((a) => a.id === keyId) ??
- (credFile ? accounts.find((a) => a.source === credFile) : accounts[0]);
-
- if (!account) {
- return c.json({ error: "no Claude credentials found" }, 500);
- }
-
- const profile = await validateAccountCredentials(account);
- if (!profile) {
- return c.json(
- {
- error: "Claude credentials are invalid or expired",
- details: "Run `claude` to re-authenticate.",
- },
- 401,
- );
- }
-
- const creds = account.credentials;
- let models = await fetchAnthropicModels(creds.accessToken);
- if (models.length === 0) {
- models = ANTHROPIC_MODELS_FALLBACK;
- }
-
- return c.json({
- models,
- subscriptionType: account.credentials.subscriptionType,
- ...(profile.email ? { email: profile.email } : {}),
- });
- }
-
- const apiKeyValue = resolveApiKey(keyId, key.definition.env);
- if (!apiKeyValue) {
- return c.json({ error: `no API key found for ${keyId}` }, 500);
- }
-
- const baseUrl = key.definition.base_url.replace(/\/+$/, "");
- const url = `${baseUrl}/models`;
- const headers: Record<string, string> = {
- Authorization: `Bearer ${apiKeyValue}`,
- };
- if (key.definition.provider === "github-copilot") {
- headers["Copilot-Integration-Id"] = "vscode-chat";
- }
-
- let response: Response;
- try {
- response = await fetch(url, { headers });
- } catch (err) {
- return c.json({ error: "provider API call failed", details: String(err) }, 502);
- }
-
- if (!response.ok) {
- const text = await response.text().catch(() => "");
- return c.json(
- { error: "provider API returned error", status: response.status, details: text },
- 502,
- );
- }
-
- let data: { data: { id: string }[] };
- try {
- data = await response.json();
- } catch (err) {
- return c.json({ error: "failed to parse provider response", details: String(err) }, 502);
- }
-
- const models = data.data.map((m) => m.id.replace(/^models\//, ""));
- return c.json({ models });
-});
-
-// Resolve a model's MAXIMUM context window (in tokens) from the models.dev
-// catalog. Returns `{ contextLimit: number | null }`; `null` means the model's
-// limit is unknown (unsupported provider, unknown model, or catalog offline),
-// which the frontend renders without a denominator/percentage.
-modelsRoutes.get("/context-limit", async (c) => {
- const provider = c.req.query("provider");
- const modelId = c.req.query("modelId");
- if (!provider || !modelId) {
- return c.json({ error: "provider and modelId query parameters are required" }, 400);
- }
-
- const contextLimit = await resolveContextLimit(provider, modelId);
- return c.json({ contextLimit });
-});
-
-// Resolve a model's image / PDF INPUT capabilities from the models.dev catalog.
-// Returns `{ capabilities: { image, pdf } | null }`. `null` means UNKNOWN — the
-// provider is unmapped, the model is absent, the catalog predates the
-// `modalities` field, or the catalog is offline. The frontend treats `null` as
-// "can't verify" (optimistic allow) and a definitive `{ image: false }` as a
-// hard block (no tokens spent).
-modelsRoutes.get("/capabilities", async (c) => {
- const provider = c.req.query("provider");
- const modelId = c.req.query("modelId");
- if (!provider || !modelId) {
- return c.json({ error: "provider and modelId query parameters are required" }, 400);
- }
-
- const capabilities = await resolveModelCapabilities(provider, modelId);
- return c.json({ capabilities });
-});
-
-// List available Claude accounts with validated credentials
-modelsRoutes.get("/claude-accounts", async (c) => {
- const candidates = resolveClaudeAccounts();
-
- // Validate each account's credentials; only include ones with a working token
- const validated: Array<{
- id: string;
- label: string;
- source: string;
- subscriptionType: string;
- expiresAt: number;
- email?: string;
- }> = [];
-
- for (const acct of candidates) {
- const profile = await validateAccountCredentials(acct);
- if (profile) {
- validated.push({
- id: acct.id,
- label: acct.label,
- source: acct.source,
- subscriptionType: acct.credentials.subscriptionType ?? "unknown",
- expiresAt: acct.credentials.expiresAt,
- ...(profile.email ? { email: profile.email } : {}),
- });
- }
- }
-
- return c.json({ accounts: validated });
-});
-
-// Get usage for a specific Claude account
-modelsRoutes.get("/claude-usage", async (c) => {
- const accountId = c.req.query("accountId");
- const accounts = getAccounts();
- const accountAccounts = resolveClaudeAccounts();
- const allAccounts = accounts.length > 0 ? accounts : accountAccounts;
-
- let account: ClaudeAccount | undefined;
- if (accountId) {
- account = allAccounts.find((a) => a.id === accountId);
- if (!account) {
- return c.json({ error: `account not found: ${accountId}` }, 404);
- }
- } else {
- account = allAccounts[0];
- }
-
- if (!account) {
- return c.json({ error: "no Claude accounts available" }, 404);
- }
-
- const report = await getAccountUsage(account);
- if (!report) {
- return c.json({ error: "failed to fetch usage data" }, 502);
- }
-
- return c.json(report);
-});
-
-// Get usage for a specific key by ID
-modelsRoutes.get("/key-usage", async (c) => {
- const keyId = c.req.query("keyId");
- if (!keyId) {
- return c.json({ error: "keyId query parameter is required" }, 400);
- }
-
- const registry = getRegistry();
- if (!registry) {
- return c.json({ error: "registry not available" }, 502);
- }
-
- const keys = registry.getKeys();
- const key = keys.find((k) => k.definition.id === keyId);
- if (!key) {
- return c.json({ error: `key not found: ${keyId}` }, 404);
- }
-
- const provider = key.definition.provider;
-
- try {
- if (provider === "anthropic") {
- const allAccounts = resolveClaudeAccounts();
- const credFile = key.definition.credentials_file;
- // Match by key ID (DB accounts) or source file (file accounts)
- const accounts = allAccounts.filter(
- (a) => a.id === keyId || (credFile && a.source === credFile),
- );
- if (accounts.length === 0 && allAccounts[0]) {
- accounts.push(allAccounts[0]);
- }
- if (accounts.length === 0) {
- return c.json({ error: "no Claude accounts available" }, 502);
- }
- // Fetch usage for matched accounts
- const accountResults = await Promise.all(
- accounts.map(async (acct) => {
- const report = await getAccountUsage(acct);
- return {
- label: acct.label,
- source: acct.source,
- subscriptionType: acct.credentials.subscriptionType,
- fiveHour: report?.fiveHour,
- sevenDay: report?.sevenDay,
- error: report ? undefined : "failed to fetch",
- };
- }),
- );
- return c.json({
- provider: "anthropic",
- accounts: accountResults,
- // Legacy single-account fields (first account)
- fiveHour: accountResults[0]?.fiveHour,
- sevenDay: accountResults[0]?.sevenDay,
- });
- } else if (provider === "opencode-go") {
- // Cookie-based HTML scraper. Uses OPENCODE_COOKIE env var plus
- // OPENCODE_WS1_ID / OPENCODE_WS2_ID (keyed by the key's numeric suffix).
- const report = await fetchOpencodeUsage(key.definition.id);
- if (report) {
- return c.json({
- provider: "opencode-go",
- fiveHour: report.fiveHour,
- weekly: report.weekly,
- monthly: report.monthly,
- });
- }
- // Fall back: show limits info with link to console
- return c.json({
- provider: "opencode-go",
- unavailable: true,
- consoleUrl: "https://opencode.ai/auth",
- limits: {
- fiveHour: "$12",
- weekly: "$30",
- monthly: "$60",
- },
- });
- } else if (provider === "github-copilot") {
- const token = resolveApiKey(keyId, key.definition.env);
- if (!token) {
- return c.json({ error: `no API key found for ${keyId}` }, 502);
- }
- const report = await fetchCopilotUsage(token, key.definition.base_url);
- if (!report) {
- return c.json({ error: "failed to fetch usage data" }, 502);
- }
- return c.json({
- provider: "github-copilot",
- tokensConsumed: report.tokensConsumed,
- tokensRemaining: report.tokensRemaining,
- percentUsed: report.percentUsed,
- resetAt: report.resetAt,
- plan: report.plan,
- });
- } else if (provider === "google") {
- const token = resolveApiKey(keyId, key.definition.env);
- if (!token) {
- return c.json({ error: `no API key found for ${keyId}. Set GOOGLE_API_KEY env var.` }, 502);
- }
- const report = await fetchGoogleUsage(token, key.definition.base_url);
- if (!report) {
- return c.json({ error: "failed to fetch Google usage data" }, 502);
- }
- return c.json({
- provider: "google",
- models: report.models,
- currentUsage: report.currentUsage,
- weeklyUsage: report.weeklyUsage,
- });
- } else {
- return c.json({ error: "usage tracking not supported for this provider" }, 400);
- }
- } catch (err) {
- const message = err instanceof Error ? err.message : String(err);
- return c.json({ error: `failed to fetch usage: ${message}` }, 502);
- }
-});
-
-// ─── API key management ───────────────────────────────────────
-
-modelsRoutes.post("/set-api-key", async (c) => {
- const body = await c.req.json<{ keyId?: string; apiKey?: string }>();
- if (typeof body.keyId !== "string" || !body.keyId) {
- return c.json({ error: "keyId is required" }, 400);
- }
- if (typeof body.apiKey !== "string" || !body.apiKey) {
- return c.json({ error: "apiKey is required" }, 400);
- }
-
- const registry = getRegistry();
- if (!registry) {
- return c.json({ error: "registry not available" }, 502);
- }
-
- const keys = registry.getKeys();
- const key = keys.find((k) => k.definition.id === body.keyId);
- if (!key) {
- return c.json({ error: `key not found: ${body.keyId}` }, 404);
- }
-
- setApiKey(body.keyId, key.definition.provider, body.apiKey);
- return c.json({ success: true, keyId: body.keyId });
-});
-
-modelsRoutes.get("/api-keys-status", (c) => {
- const stored = listApiKeys();
- return c.json({ keys: stored });
-});
-
-// ─── Credential import ────────────────────────────────────────
-
-modelsRoutes.post("/import-credentials", async (c) => {
- const body = await c.req.json<{ keyId?: string }>();
- const keyId = body.keyId;
- if (typeof keyId !== "string" || !keyId) {
- return c.json({ error: "keyId is required" }, 400);
- }
-
- const registry = getRegistry();
- if (!registry) {
- return c.json({ error: "registry not available" }, 502);
- }
-
- const keys = registry.getKeys();
- const key = keys.find((k) => k.definition.id === keyId);
- if (!key) {
- return c.json({ error: `key not found: ${keyId}` }, 404);
- }
-
- if (key.definition.provider !== "anthropic") {
- return c.json({ error: "credential import is only supported for anthropic keys" }, 400);
- }
-
- const credFile = key.definition.credentials_file;
- if (!credFile) {
- return c.json({ error: "no credentials_file configured for this key" }, 400);
- }
-
- const result = importCredentialsFromFile(keyId, key.definition.provider, credFile);
- if (!result.success) {
- return c.json({ error: result.error ?? "import failed" }, 400);
- }
-
- return c.json({ success: true, keyId });
-});
-
-modelsRoutes.get("/credentials-status", (c) => {
- const stored = listStoredCredentials();
- const status = stored.map((cred) => ({
- keyId: cred.keyId,
- provider: cred.provider,
- subscriptionType: cred.subscriptionType,
- sourceFile: cred.sourceFile,
- importedAt: cred.importedAt,
- updatedAt: cred.updatedAt,
- expired: cred.expiresAt < Date.now(),
- }));
- return c.json({ credentials: status });
-});
-
-// ─── Add key to dispatch.toml ─────────────────────────────────
-
-const VALID_PROVIDERS = ["anthropic", "opencode-go", "google"] as const;
-type SupportedProvider = (typeof VALID_PROVIDERS)[number];
-
-const PROVIDER_BASE_URLS: Record<SupportedProvider, string> = {
- anthropic: "https://api.anthropic.com/v1",
- "opencode-go": "https://opencode.ai/zen/go/v1",
- google: "https://generativelanguage.googleapis.com/v1beta/openai",
-};
-
-modelsRoutes.post("/add-key", async (c) => {
- const body = await c.req.json<{ id?: unknown; provider?: unknown }>();
-
- // Validate id
- if (typeof body.id !== "string" || !body.id.trim() || !/^[a-zA-Z0-9_-]+$/.test(body.id.trim())) {
- return c.json({ error: "id must contain only letters, numbers, dashes, and underscores" }, 400);
- }
- const id = body.id.trim();
-
- // Validate provider
- if (!VALID_PROVIDERS.includes(body.provider as SupportedProvider)) {
- return c.json({ error: `provider must be one of: ${VALID_PROVIDERS.join(", ")}` }, 400);
- }
- const provider = body.provider as SupportedProvider;
- const base_url = PROVIDER_BASE_URLS[provider];
-
- // Read current dispatch.toml
- const tomlPath = `${process.cwd()}/dispatch.toml`;
- let tomlContent: string;
- try {
- tomlContent = readFileSync(tomlPath, "utf-8");
- } catch (err) {
- return c.json({ error: `failed to read dispatch.toml: ${String(err)}` }, 500);
- }
-
- // Check for duplicate key id
- const idPattern = new RegExp(`^\\s*id\\s*=\\s*["']?${id}["']?\\s*$`, "m");
- if (idPattern.test(tomlContent)) {
- return c.json({ error: `key with id "${id}" already exists` }, 409);
- }
-
- // Build the new [[keys]] block
- let newBlock = `\n[[keys]]\nid = "${id}"\nprovider = "${provider}"\nbase_url = "${base_url}"`;
- if (provider === "anthropic") {
- const credPath = `${homedir()}/.claude/.credentials-${id}.json`;
- newBlock += `\ncredentials_file = "${credPath}"`;
- } else {
- const envVar =
- provider === "google"
- ? "GOOGLE_API_KEY"
- : `DISPATCH_${id.toUpperCase().replace(/-/g, "_")}_KEY`;
- newBlock += `\nenv = "${envVar}"`;
- }
- newBlock += "\n";
-
- // Insert before the # ─── Permissions section if it exists, otherwise at end
- const permissionsMarker = /\n# [─-]+ Permissions/;
- let newContent: string;
- const permMatch = permissionsMarker.exec(tomlContent);
- if (permMatch) {
- const insertAt = permMatch.index;
- newContent = tomlContent.slice(0, insertAt) + newBlock + tomlContent.slice(insertAt);
- } else {
- newContent = tomlContent + newBlock;
- }
-
- try {
- writeFileSync(tomlPath, newContent, "utf-8");
- } catch (err) {
- return c.json({ error: `failed to write dispatch.toml: ${String(err)}` }, 500);
- }
-
- const key: { id: string; provider: string; base_url: string; credentials_file?: string } = {
- id,
- provider,
- base_url,
- };
- if (provider === "anthropic") {
- key.credentials_file = `${homedir()}/.claude/.credentials-${id}.json`;
- }
-
- return c.json({ success: true, key });
-});
-
-// ─── Remove key from dispatch.toml ────────────────────────────
-
-modelsRoutes.post("/remove-key", async (c) => {
- const body = await c.req.json<{ id?: unknown }>();
-
- if (typeof body.id !== "string" || !body.id.trim()) {
- return c.json({ error: "id is required" }, 400);
- }
- const id = body.id.trim();
-
- const tomlPath = `${process.cwd()}/dispatch.toml`;
- let tomlContent: string;
- try {
- tomlContent = readFileSync(tomlPath, "utf-8");
- } catch (err) {
- return c.json({ error: `failed to read dispatch.toml: ${String(err)}` }, 500);
- }
-
- // Match the [[keys]] block containing this id and remove it.
- // A block starts with [[keys]] and ends at the next [[...]] header, # ─── section marker, or EOF.
- const blockPattern = new RegExp(
- `\\n?\\[\\[keys\\]\\]\\n(?:[^\\[#]|#(?! [─\\-]))*?id\\s*=\\s*"${id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"[^\\[#]*(?:\\n(?=\\[|# [─\\-])|$)`,
- "s",
- );
- const match = blockPattern.exec(tomlContent);
- if (!match) {
- return c.json({ error: `key "${id}" not found in dispatch.toml` }, 404);
- }
-
- const newContent =
- tomlContent.slice(0, match.index) + tomlContent.slice(match.index + match[0].length);
-
- try {
- writeFileSync(tomlPath, newContent, "utf-8");
- } catch (err) {
- return c.json({ error: `failed to write dispatch.toml: ${String(err)}` }, 500);
- }
-
- return c.json({ success: true });
-});
-
-// ─── Shared wake function ─────────────────────────────────────
-
-/** Max chars of upstream error body to keep in the surfaced message. */
-const MAX_ERROR_BODY_CHARS = 200;
-
-/**
- * Turn a non-OK probe response into a short, human-readable reason. Anthropic
- * returns a JSON error envelope (`{ error: { message } }`); fall back to a
- * truncated raw body, then to the bare status. Never throws.
- */
-async function describeFailedResponse(res: Response): Promise<string> {
- let detail = "";
- try {
- const text = await res.text();
- try {
- const parsed = JSON.parse(text) as { error?: { message?: unknown } };
- const message = parsed?.error?.message;
- detail = typeof message === "string" ? message : text;
- } catch {
- detail = text;
- }
- } catch {
- detail = "";
- }
- detail = detail.trim().slice(0, MAX_ERROR_BODY_CHARS);
- return detail ? `HTTP ${res.status}: ${detail}` : `HTTP ${res.status}`;
-}
-
-async function wakeAllClaudeAccounts(): Promise<
- Array<{ label: string; ok: boolean; error?: string }>
-> {
- // Only wake accounts referenced by configured anthropic keys
- const allAccounts = resolveClaudeAccounts();
- const registry = getRegistry();
- const configuredKeyIds = new Set<string>();
- if (registry) {
- for (const ks of registry.getKeys()) {
- if (ks.definition.provider === "anthropic") {
- configuredKeyIds.add(ks.definition.id);
- }
- }
- }
- const accounts =
- configuredKeyIds.size > 0 ? allAccounts.filter((a) => configuredKeyIds.has(a.id)) : allAccounts;
- if (accounts.length === 0) {
- return [{ label: "(none)", ok: false, error: "no Claude accounts available" }];
- }
-
- const results: Array<{ label: string; ok: boolean; error?: string }> = [];
-
- for (const acct of accounts) {
- try {
- const creds = await refreshAccountCredentialsAsync(acct);
- if (!creds) {
- results.push({ label: acct.label, ok: false, error: "token refresh failed" });
- continue;
- }
-
- // Resolve the probe model dynamically. A fixed model id (the old
- // `claude-3-5-haiku-20241022`) eventually stops being served and
- // the probe 404s, so pull the live list from `/v1/models` and pick
- // the current Haiku. Fall back to the well-known list if the live
- // fetch comes back empty (network blip, transient upstream error).
- let availableModels = await fetchAnthropicModels(creds.accessToken);
- if (availableModels.length === 0) {
- availableModels = ANTHROPIC_MODELS_FALLBACK;
- }
- const probeModel = selectHaikuModel(availableModels);
- if (!probeModel) {
- results.push({
- label: acct.label,
- ok: false,
- error: "no 'haiku' model available from /v1/models",
- });
- continue;
- }
-
- // Mirror a genuine Claude Code CLI request. These are OAuth
- // (Pro/Max) subscription accounts: Anthropic validates the
- // `system[]` array and rejects (401/403) any request whose system
- // block lacks the verbatim Claude Code identity string. A bare
- // `{ model, messages }` body — what this probe used to send —
- // always failed, which is why scheduled wakes silently died with a
- // blank "failed" status. `buildWakeProbeBody` produces the correct
- // shape (billing header + identity); the session/request-id headers
- // match what the real CLI stamps so the probe isn't flagged.
- const res = await fetch("https://api.anthropic.com/v1/messages", {
- method: "POST",
- headers: {
- ...getAnthropicHeaders(creds.accessToken),
- "content-type": "application/json",
- "X-Claude-Code-Session-Id": randomUUID(),
- "x-client-request-id": randomUUID(),
- },
- body: JSON.stringify(buildWakeProbeBody(probeModel)),
- });
-
- if (res.ok) {
- results.push({ label: acct.label, ok: true });
- } else {
- // Surface WHY it failed so the panel never shows a bare
- // "failed" again and breakage stays debuggable.
- results.push({
- label: acct.label,
- ok: false,
- error: await describeFailedResponse(res),
- });
- }
- } catch (err) {
- results.push({
- label: acct.label,
- ok: false,
- error: err instanceof Error ? err.message : String(err),
- });
- }
- }
-
- return results;
-}
-
-modelsRoutes.post("/wake", async (c) => {
- const results = await wakeAllClaudeAccounts();
- return c.json({ results });
-});
-
-// ─── Wake scheduler (runs on backend, survives frontend close) ─
-//
-// A "marked hour" expands to 4 probe slots inside that hour: :00, :15, :30,
-// :45. Each slot is its own (hour, slot_minute) row in `wake_schedule` with
-// its own `next_wake_at`. When multiple slots come due in the same tick we
-// coalesce into a single upstream wake — no point hitting Anthropic 4× in
-// the same 30-second window.
-
-/** Schedule: hour (0-23) → slot minute (0/15/30/45) → next fire ms. */
-type WakeSchedule = Record<number, Partial<Record<ProbeSlotMinute, number>>>;
-
-interface PendingRetry {
- /** Remaining attempts. Starts at MAX_RETRIES (e.g. 6 → 30 min of retries). */
- retriesLeft: number;
- /** Absolute timestamp (ms) of the next retry attempt. */
- nextRetryAt: number;
- /** Why we entered retry mode — surfaced on /wake-schedule. */
- reason: string;
-}
-
-interface LastWake {
- firedAt: number;
- ok: boolean;
- results: Array<{ label: string; ok: boolean; error?: string }>;
-}
-
-const MAX_RETRIES = 6;
-const RETRY_INTERVAL_MS = 5 * 60 * 1000;
-const TICK_INTERVAL_MS = 30_000;
-
-function setSlot(schedule: WakeSchedule, hour: number, minute: ProbeSlotMinute, ts: number): void {
- const hourEntry = schedule[hour] ?? {};
- hourEntry[minute] = ts;
- schedule[hour] = hourEntry;
-}
-
-function deleteHour(schedule: WakeSchedule, hour: number): void {
- delete schedule[hour];
-}
-
-function countSlots(schedule: WakeSchedule): number {
- let n = 0;
- for (const slots of Object.values(schedule)) {
- n += Object.keys(slots).length;
- }
- return n;
-}
-
-function loadScheduleFromDB(): WakeSchedule {
- try {
- const db = getDatabase();
- const rows = db
- .query("SELECT hour, slot_minute, next_wake_at FROM wake_schedule")
- .all() as Array<{ hour: number; slot_minute: number; next_wake_at: number }>;
- const schedule: WakeSchedule = {};
- const now = Date.now();
- let needsPersist = false;
- let anyShouldFire = false;
- for (const row of rows) {
- if (!isProbeSlotMinute(row.slot_minute)) continue; // defensive — schema CHECKs it
- const recovered = recoverScheduleEntry(row.next_wake_at, now);
- setSlot(schedule, row.hour, row.slot_minute, recovered.nextWakeAt);
- if (recovered.nextWakeAt !== row.next_wake_at) needsPersist = true;
- if (recovered.shouldFireNow) anyShouldFire = true;
- }
- if (needsPersist) persistSchedule(schedule);
- if (anyShouldFire) needsBootFire = true;
- return schedule;
- } catch {
- return {};
- }
-}
-
-function persistSchedule(scheduleToSave?: WakeSchedule): void {
- try {
- const db = getDatabase();
- const data = scheduleToSave ?? wakeSchedule;
- const insert = db.query(
- "INSERT INTO wake_schedule (hour, slot_minute, next_wake_at) VALUES ($hour, $slot, $nextWakeAt)",
- );
- // One atomic transaction: DELETE + every INSERT either all commit or all
- // roll back. Without this, an INSERT failure (disk full, bad row, etc.)
- // would leave the table empty — silently wiping the user's schedule on
- // next boot since the DELETE has already committed.
- const writeAll = db.transaction(() => {
- db.run("DELETE FROM wake_schedule");
- for (const [hour, slots] of Object.entries(data)) {
- for (const [slotMinute, nextWakeAt] of Object.entries(slots)) {
- if (nextWakeAt === undefined) continue;
- insert.run({
- $hour: Number(hour),
- $slot: Number(slotMinute),
- $nextWakeAt: nextWakeAt,
- });
- }
- }
- });
- writeAll();
- } catch {
- // Ignore DB errors — schedule still lives in-memory for this process,
- // and the previously persisted snapshot stays intact thanks to the
- // transaction rollback above.
- }
-}
-
-/** Set to true by loadScheduleFromDB when one or more slots need a boot fire. */
-let needsBootFire = false;
-const wakeSchedule: WakeSchedule = loadScheduleFromDB();
-
-/**
- * A single shared retry slot. We deliberately do NOT queue one retry per
- * failed wake — multiple back-to-back failures (e.g. the network is down for
- * five minutes) used to spawn retries that all converged on the same instant
- * and hammered the upstream. One in-flight retry covers all accounts.
- */
-let pendingRetry: PendingRetry | null = null;
-let lastWake: LastWake | null = null;
-
-// HMR-safe: track the scheduler timer on globalThis so re-imports during dev
-// don't leave orphaned timers running.
-const timerKey = "_dispatchWakeTimer";
-(globalThis as Record<string, unknown>)[timerKey] ??= undefined;
-let isTickRunning = false;
-
-function recordWake(results: Array<{ label: string; ok: boolean; error?: string }>): boolean {
- const ok = results.length > 0 && results.every((r) => r.ok);
- lastWake = { firedAt: Date.now(), ok, results };
- return ok;
-}
-
-function scheduleRetry(reason: string): void {
- if (pendingRetry) {
- // Already retrying — reset the budget so the next failure window covers
- // the new incident too, but don't compound timers.
- pendingRetry.retriesLeft = MAX_RETRIES;
- pendingRetry.nextRetryAt = Date.now() + RETRY_INTERVAL_MS;
- pendingRetry.reason = reason;
- return;
- }
- pendingRetry = {
- retriesLeft: MAX_RETRIES,
- nextRetryAt: Date.now() + RETRY_INTERVAL_MS,
- reason,
- };
-}
-
-async function fireWake(reason: string): Promise<void> {
- try {
- const results = await wakeAllClaudeAccounts();
- const ok = recordWake(results);
- if (!ok) scheduleRetry(reason);
- } catch (err) {
- const message = err instanceof Error ? err.message : String(err);
- lastWake = {
- firedAt: Date.now(),
- ok: false,
- results: [{ label: "(scheduler)", ok: false, error: message }],
- };
- scheduleRetry(reason);
- }
-}
-
-async function processPendingRetry(now: number): Promise<void> {
- // Capture into a local so TS narrowing survives across awaits, and so a
- // racing toggle that clears `pendingRetry` mid-flight can't NPE us.
- const retry = pendingRetry;
- if (!retry || retry.nextRetryAt > now) return;
- try {
- const results = await wakeAllClaudeAccounts();
- const ok = recordWake(results);
- if (ok || retry.retriesLeft <= 1) {
- pendingRetry = null;
- } else {
- retry.retriesLeft -= 1;
- retry.nextRetryAt = Date.now() + RETRY_INTERVAL_MS;
- }
- } catch (err) {
- const message = err instanceof Error ? err.message : String(err);
- lastWake = {
- firedAt: Date.now(),
- ok: false,
- results: [{ label: "(retry)", ok: false, error: message }],
- };
- if (retry.retriesLeft <= 1) {
- pendingRetry = null;
- } else {
- retry.retriesLeft -= 1;
- retry.nextRetryAt = Date.now() + RETRY_INTERVAL_MS;
- }
- }
-}
-
-interface DueSlot {
- hour: number;
- minute: ProbeSlotMinute;
- ts: number;
-}
-
-/** Collect every slot whose next_wake_at is at or before `now`. */
-function collectDueSlots(now: number): DueSlot[] {
- const due: DueSlot[] = [];
- for (const [hourStr, slots] of Object.entries(wakeSchedule)) {
- const hour = Number(hourStr);
- for (const [slotStr, ts] of Object.entries(slots)) {
- if (ts === undefined) continue;
- const slotMinute = Number(slotStr);
- if (!isProbeSlotMinute(slotMinute)) continue;
- if (ts <= now) due.push({ hour, minute: slotMinute, ts });
- }
- }
- return due;
-}
-
-async function schedulerTick(): Promise<void> {
- // Prevent concurrent tick execution (e.g. toggle called mid-tick).
- if (isTickRunning) return;
- isTickRunning = true;
-
- try {
- const now = Date.now();
- const due = collectDueSlots(now);
-
- let firedThisTick = false;
- const bootFireRequested = needsBootFire;
- if (due.length > 0 || bootFireRequested) {
- needsBootFire = false;
- // Advance every due slot before firing — so a slow upstream call
- // can't cause us to re-fire the same slot on the next tick.
- for (const slot of due) {
- const next = nextDailyAfter(slot.ts, now);
- setSlot(wakeSchedule, slot.hour, slot.minute, next);
- }
- persistSchedule();
-
- const reasonParts = due.map((d) => `${d.hour}:${String(d.minute).padStart(2, "0")}`);
- const fromBoot = bootFireRequested ? " (boot recovery)" : "";
- const reason =
- reasonParts.length > 0
- ? `scheduled probe(s) ${reasonParts.join(", ")}${fromBoot}`
- : "boot recovery";
- firedThisTick = true;
- // COALESCED: one upstream call covers all slots due this tick.
- await fireWake(reason);
- }
-
- // Only attempt a retry on ticks that didn't *just* fire — otherwise we'd
- // race the retry against a fresh attempt within the same loop iteration.
- if (!firedThisTick) {
- await processPendingRetry(Date.now());
- }
-
- // Keep ticking while there's anything to monitor.
- if (countSlots(wakeSchedule) > 0 || pendingRetry !== null) {
- (globalThis as Record<string, unknown>)[timerKey] = setTimeout(
- schedulerTick,
- TICK_INTERVAL_MS,
- );
- }
- } finally {
- isTickRunning = false;
- }
-}
-
-export function startWakeScheduler(): void {
- // Clear any previous timer (HMR-safe — works with Bun's Timer objects).
- const prev = (globalThis as Record<string, unknown>)[timerKey];
- if (prev != null) clearTimeout(prev as ReturnType<typeof setTimeout>);
- // Fire-and-forget; the tick re-arms itself.
- void schedulerTick();
-}
-
-function scheduleSnapshot(): {
- schedule: WakeSchedule;
- resetOffsetHours: number;
- probeSlotMinutes: readonly number[];
- lastWake: LastWake | null;
- pendingRetry: PendingRetry | null;
-} {
- return {
- schedule: wakeSchedule,
- resetOffsetHours: CLAUDE_RESET_OFFSET_HOURS,
- probeSlotMinutes: PROBE_SLOT_MINUTES,
- lastWake,
- pendingRetry,
- };
-}
-
-modelsRoutes.post("/wake-schedule/toggle", async (c) => {
- const body = await c.req.json<{
- hour?: unknown;
- action?: unknown;
- timestamps?: unknown;
- }>();
- const hour = body.hour;
- if (typeof hour !== "number" || !Number.isFinite(hour) || hour < 0 || hour > 23) {
- return c.json({ error: "hour must be a number 0-23" }, 400);
- }
- if (!Number.isInteger(hour)) {
- return c.json({ error: "hour must be an integer 0-23" }, 400);
- }
-
- // The action is the CLIENT'S DECLARED INTENT. Previously the server
- // derived add-vs-remove from its own in-memory state, which meant a UI
- // that had become stale (e.g. due to a snapshot race) would have its
- // clicks silently inverted: user clicks to turn ON an hour the UI shows
- // as OFF, server sees it as already-ON, deletes it. Requiring an explicit
- // action makes the request idempotent and self-describing — a stale UI's
- // click is now either a redundant no-op (action matches server state) or
- // a recoverable replace (action="on" against an already-on hour just
- // refreshes its timestamps to the new values).
- const action = body.action;
- if (action !== "on" && action !== "off") {
- return c.json({ error: "action must be 'on' or 'off'" }, 400);
- }
-
- if (action === "off") {
- // Idempotent: removing an already-removed hour is a no-op success.
- if (wakeSchedule[hour] !== undefined) {
- deleteHour(wakeSchedule, hour);
- }
- } else {
- // action === "on" — require a `timestamps` object with one absolute
- // Unix ms per probe slot (0, 15, 30, 45). The client is the source
- // of truth for the *local* wall-clock intent of each probe.
- // Idempotent: turning ON an already-on hour replaces its timestamps
- // (so a UI recovering from a desync can re-assert the correct wall-
- // clock intent without first deleting).
- const timestamps = body.timestamps;
- if (timestamps === null || typeof timestamps !== "object") {
- return c.json(
- { error: "timestamps must be an object { '0': ms, '15': ms, '30': ms, '45': ms }" },
- 400,
- );
- }
- const parsed: Partial<Record<ProbeSlotMinute, number>> = {};
- for (const slot of PROBE_SLOT_MINUTES) {
- const raw = (timestamps as Record<string, unknown>)[String(slot)];
- // Accept any finite Unix-ms number. We deliberately do NOT reject
- // past timestamps: client-server clock skew + request latency mean
- // a freshly-computed `nextOccurrenceAt(HH:MM)` for an imminent slot
- // can land "in the past" by the time the server validates it. The
- // scheduler tick handles past entries correctly via
- // `recoverScheduleEntry` — fires within MISSED_WAKE_GRACE_MS, then
- // advances by 24h * N to the next future occurrence.
- if (typeof raw !== "number" || !Number.isFinite(raw)) {
- return c.json({ error: `timestamps['${slot}'] must be a finite Unix ms value` }, 400);
- }
- parsed[slot] = raw;
- }
- wakeSchedule[hour] = parsed;
- }
-
- persistSchedule();
- startWakeScheduler();
-
- return c.json(scheduleSnapshot());
-});
-
-modelsRoutes.get("/wake-schedule", (c) => {
- return c.json(scheduleSnapshot());
-});
diff --git a/packages/api/src/routes/notifications.ts b/packages/api/src/routes/notifications.ts
deleted file mode 100644
index 473e837..0000000
--- a/packages/api/src/routes/notifications.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-// `/notifications` — ntfy.sh config + test-send route.
-
-import {
- defaultNtfyConfig,
- loadNtfyConfig,
- type NotificationEventType,
- NTFY_EVENT_TYPES,
- type NtfyConfig,
- normalizeNtfyConfig,
- redactNtfyConfig,
- saveNtfyConfig,
- sendNtfy,
-} from "@dispatch/core";
-import { Hono } from "hono";
-
-export const notificationsRoutes = new Hono();
-
-notificationsRoutes.get("/", (c) => {
- const config = loadNtfyConfig();
- return c.json({
- config: redactNtfyConfig(config),
- eventTypes: NTFY_EVENT_TYPES,
- defaults: defaultNtfyConfig(),
- });
-});
-
-notificationsRoutes.put("/", async (c) => {
- const body = await c.req.json<Partial<NtfyConfig> & { authToken?: string }>();
- const existing = loadNtfyConfig();
-
- // `authToken === ""` ⇒ explicit clear; `authToken === undefined` ⇒ keep
- // the existing token (the GET response redacts it, so the frontend doesn't
- // have it to send back). Any other string ⇒ replace.
- let nextAuthToken = existing.authToken;
- if (typeof body.authToken === "string") nextAuthToken = body.authToken;
-
- const merged = normalizeNtfyConfig({
- enabled: typeof body.enabled === "boolean" ? body.enabled : existing.enabled,
- topic: typeof body.topic === "string" ? body.topic : existing.topic,
- authToken: nextAuthToken,
- events: { ...existing.events, ...(body.events ?? {}) },
- notifySubagents:
- typeof body.notifySubagents === "boolean" ? body.notifySubagents : existing.notifySubagents,
- });
-
- // Only validation: if notifications are turned on, the topic must be
- // non-empty. Any other "is this a valid ntfy topic name?" check is
- // punted to the ntfy server itself — its rules vary and have changed
- // over time, and a syntactically-valid name still might be rejected
- // (e.g. reserved words), so a clear server error is more useful than
- // a client-side guess.
- if (merged.enabled && !merged.topic.trim()) {
- return c.json({ error: "Topic is required" }, 400);
- }
-
- saveNtfyConfig(merged);
- return c.json({ config: redactNtfyConfig(merged) });
-});
-
-notificationsRoutes.post("/test", async (c) => {
- const config = loadNtfyConfig();
- if (!config.enabled) {
- return c.json({ ok: false, error: "Notifications are disabled" }, 400);
- }
- if (!config.topic.trim()) {
- return c.json({ ok: false, error: "Topic is required" }, 400);
- }
-
- // Use a real event type so the per-event toggle is honored when wiring
- // is tested end-to-end; pick `turn-completed` since it's the most
- // common enabled-by-default event.
- const eventType: NotificationEventType = "turn-completed";
- if (!config.events[eventType]) {
- return c.json(
- { ok: false, error: `Event type "${eventType}" is disabled — enable it to test.` },
- 400,
- );
- }
-
- const result = await sendNtfy(config, {
- type: eventType,
- title: "Dispatch test notification",
- message: "If you can see this, ntfy.sh notifications are wired up correctly.",
- tags: ["bell"],
- });
- if (!result.ok) return c.json(result, 502);
- return c.json(result);
-});
diff --git a/packages/api/src/routes/skills.ts b/packages/api/src/routes/skills.ts
deleted file mode 100644
index 7696b47..0000000
--- a/packages/api/src/routes/skills.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import type { AgentSkillMapping, SkillDefinition, SkillScope } from "@dispatch/core";
-import { Hono } from "hono";
-
-let getSkills: () => { skills: SkillDefinition[]; mappings: AgentSkillMapping[] } = () => ({
- skills: [],
- mappings: [],
-});
-
-export function setSkillsGetter(
- getter: () => { skills: SkillDefinition[]; mappings: AgentSkillMapping[] },
-): void {
- getSkills = getter;
-}
-
-export const skillsRoutes = new Hono();
-
-skillsRoutes.get("/", (c) => {
- const { skills, mappings } = getSkills();
- const skillSummaries = skills.map(({ name, description, tags, scope, directory }) => ({
- name,
- description,
- tags,
- scope,
- directory,
- }));
- return c.json({ skills: skillSummaries, mappings });
-});
-
-skillsRoutes.get("/:name", (c) => {
- const { name } = c.req.param();
- const scopeParam = c.req.query("scope") as SkillScope | undefined;
- const { skills } = getSkills();
-
- const matches = skills.filter((s) => s.name === name);
- if (matches.length === 0) {
- return c.json({ error: "Skill not found" }, 404);
- }
-
- if (scopeParam) {
- const scoped = matches.find((s) => s.scope === scopeParam);
- if (!scoped) {
- return c.json({ error: "Skill not found" }, 404);
- }
- return c.json(scoped);
- }
-
- return c.json(matches[0]);
-});
diff --git a/packages/api/src/routes/tabs.ts b/packages/api/src/routes/tabs.ts
deleted file mode 100644
index 2ae60ed..0000000
--- a/packages/api/src/routes/tabs.ts
+++ /dev/null
@@ -1,229 +0,0 @@
-import {
- archiveTab,
- createTab,
- deleteSetting,
- getChunksForTab,
- getSetting,
- getTab,
- getTotalChunkCount,
- getUsageStatsForTab,
- groupRowsToMessages,
- listOpenTabs,
- setSetting,
- updateTabModel,
- updateTabPositions,
- updateTabStatus,
- updateTabTitle,
-} from "@dispatch/core";
-import { Hono } from "hono";
-
-export const tabsRoutes = new Hono();
-
-let getAgentManager: () => {
- stopTab(id: string): void;
- deleteTab(id: string): void;
- compactTab(tempTabId: string, sourceTabId: string): Promise<void>;
-} | null = () => null;
-
-export function setTabsAgentManager(
- getter: () => {
- stopTab(id: string): void;
- deleteTab(id: string): void;
- compactTab(tempTabId: string, sourceTabId: string): Promise<void>;
- } | null,
-): void {
- getAgentManager = getter;
-}
-
-tabsRoutes.get("/", (c) => {
- // Enrich each tab with its persisted usage aggregate so the frontend can
- // seed `cacheStats` on reload without an extra round-trip. N small indexed
- // queries — fine for tab counts.
- const tabs = listOpenTabs().map((t) => ({ ...t, usageStats: getUsageStatsForTab(t.id) }));
- return c.json({ tabs });
-});
-
-tabsRoutes.post("/", async (c) => {
- const body = await c.req.json<{ id?: string; title?: string }>();
- const id = body.id ?? crypto.randomUUID();
- const title = body.title ?? "New Tab";
- const tab = createTab(id, title);
- return c.json(tab);
-});
-
-// Settings routes (must be before /:id to avoid conflict)
-tabsRoutes.get("/settings/title-model", (c) => {
- const keyId = getSetting("title_model_key_id");
- const modelId = getSetting("title_model_id");
- return c.json({ keyId, modelId });
-});
-
-tabsRoutes.put("/settings/title-model", async (c) => {
- const body = await c.req.json<{ keyId?: string | null; modelId?: string | null }>();
- if (body.keyId !== undefined) {
- if (body.keyId) setSetting("title_model_key_id", body.keyId);
- else deleteSetting("title_model_key_id");
- }
- if (body.modelId !== undefined) {
- if (body.modelId) setSetting("title_model_id", body.modelId);
- else deleteSetting("title_model_id");
- }
- return c.json({ success: true });
-});
-
-// Conversation-compaction model (key+model used to generate the summary).
-// Mirrors the title-model setting. When unset, compaction falls back to the
-// source tab's own key+model.
-tabsRoutes.get("/settings/compaction-model", (c) => {
- const keyId = getSetting("compaction_model_key_id");
- const modelId = getSetting("compaction_model_id");
- return c.json({ keyId, modelId });
-});
-
-tabsRoutes.put("/settings/compaction-model", async (c) => {
- const body = await c.req.json<{ keyId?: string | null; modelId?: string | null }>();
- if (body.keyId !== undefined) {
- if (body.keyId) setSetting("compaction_model_key_id", body.keyId);
- else deleteSetting("compaction_model_key_id");
- }
- if (body.modelId !== undefined) {
- if (body.modelId) setSetting("compaction_model_id", body.modelId);
- else deleteSetting("compaction_model_id");
- }
- return c.json({ success: true });
-});
-
-// Reorder open tabs. Body `{ ids }` is the new left-to-right order of tab ids;
-// each tab's `position` is rewritten to its index. Must be declared before the
-// `/:id` routes so "reorder" isn't captured as an id param.
-tabsRoutes.patch("/reorder", async (c) => {
- const body = await c.req.json<{ ids?: string[] }>();
- if (!Array.isArray(body.ids) || body.ids.some((id) => typeof id !== "string")) {
- return c.json({ error: "ids must be an array of strings" }, 400);
- }
- updateTabPositions(body.ids);
- return c.json({ success: true });
-});
-
-tabsRoutes.get("/:id", (c) => {
- const id = c.req.param("id");
- const tab = getTab(id);
- if (!tab) return c.json({ error: "tab not found" }, 404);
- return c.json(tab);
-});
-
-// Conversation history for a tab, paginated at CHUNK granularity. The flat
-// chunk log is windowed by `limit`/`before` (both chunk-`seq` cursors) so a
-// single huge turn never dumps in full, then grouped into render messages.
-// `before` is the oldest chunk seq the client already holds. This is what
-// powers per-chunk frontend pagination / memory control.
-tabsRoutes.get("/:id/messages", (c) => {
- const id = c.req.param("id");
- const limitRaw = c.req.query("limit");
- const beforeRaw = c.req.query("before");
- const limit = limitRaw !== undefined ? Number(limitRaw) : undefined;
- const before = beforeRaw !== undefined ? Number(beforeRaw) : undefined;
- const options =
- limit !== undefined || before !== undefined
- ? {
- ...(limit !== undefined && Number.isFinite(limit) ? { limit } : {}),
- ...(before !== undefined && Number.isFinite(before) ? { before } : {}),
- }
- : undefined;
- const chunks = getChunksForTab(id, options);
- const messages = groupRowsToMessages(chunks);
- // `oldestSeq` is the chunk-seq cursor the client pages backward from; null
- // when the window is empty.
- const oldestSeq = chunks.length > 0 ? (chunks[0]?.seq ?? null) : null;
- const total = getTotalChunkCount(id);
- return c.json({ messages, total, oldestSeq });
-});
-
-// Raw chunk window for a tab — the chunk-native frontend's load/paginate
-// source. Same `limit`/`before` chunk-`seq` windowing as `/messages`, but
-// returns the flat `ChunkRow[]` WITHOUT server-side grouping (the frontend
-// groups for render and evicts/paginates on the flat list). Dedupe on the
-// client by `seq` when overlap-fetching.
-tabsRoutes.get("/:id/chunks", (c) => {
- const id = c.req.param("id");
- const limitRaw = c.req.query("limit");
- const beforeRaw = c.req.query("before");
- const limit = limitRaw !== undefined ? Number(limitRaw) : undefined;
- const before = beforeRaw !== undefined ? Number(beforeRaw) : undefined;
- const options =
- limit !== undefined || before !== undefined
- ? {
- ...(limit !== undefined && Number.isFinite(limit) ? { limit } : {}),
- ...(before !== undefined && Number.isFinite(before) ? { before } : {}),
- }
- : undefined;
- const chunks = getChunksForTab(id, options);
- const oldestSeq = chunks.length > 0 ? (chunks[0]?.seq ?? null) : null;
- const total = getTotalChunkCount(id);
- return c.json({ chunks, total, oldestSeq });
-});
-
-// Trigger conversation compaction. The `:id` is the TRANSIENT placeholder tab
-// hosting the "compacting…" UI; `sourceTabId` (body) is the conversation being
-// compacted. Fire-and-forget on the server: progress/outcome is delivered via
-// the `compaction-*` WS events. Returns 202 once the run is kicked off.
-tabsRoutes.post("/:id/compact", async (c) => {
- const tempTabId = c.req.param("id");
- const body = await c.req
- .json<{ sourceTabId?: string }>()
- .catch(() => ({}) as { sourceTabId?: string });
- const sourceTabId = body.sourceTabId;
- if (!sourceTabId || typeof sourceTabId !== "string") {
- return c.json({ error: "sourceTabId is required" }, 400);
- }
- const mgr = getAgentManager();
- if (!mgr) return c.json({ error: "agent manager unavailable" }, 503);
- // Run in the background; outcome is emitted over WS.
- void mgr.compactTab(tempTabId, sourceTabId).catch((err) => {
- console.error(`[dispatch] compactTab error for ${sourceTabId}:`, err);
- });
- return c.json({ success: true }, 202);
-});
-
-tabsRoutes.patch("/:id", async (c) => {
- const id = c.req.param("id");
- const body = await c.req.json<{
- title?: string;
- keyId?: string;
- modelId?: string;
- status?: string;
- }>();
- if (body.title !== undefined) updateTabTitle(id, body.title);
- if (body.keyId !== undefined || body.modelId !== undefined) {
- updateTabModel(id, body.keyId ?? null, body.modelId ?? null);
- }
- if (body.status !== undefined) updateTabStatus(id, body.status);
- const tab = getTab(id);
- return c.json(tab);
-});
-
-// ─── Settings ─────────────────────────────────────────────────
-
-tabsRoutes.get("/settings/:key", (c) => {
- const key = c.req.param("key");
- const value = getSetting(key);
- return c.json({ value });
-});
-
-tabsRoutes.put("/settings/:key", async (c) => {
- const key = c.req.param("key");
- const body = await c.req.json<{ value?: string }>();
- if (typeof body.value !== "string") {
- return c.json({ error: "value is required" }, 400);
- }
- setSetting(key, body.value);
- return c.json({ success: true });
-});
-
-tabsRoutes.delete("/:id", (c) => {
- const id = c.req.param("id");
- const mgr = getAgentManager();
- if (mgr) mgr.deleteTab(id);
- archiveTab(id);
- return c.json({ success: true });
-});
diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts
deleted file mode 100644
index a88e41b..0000000
--- a/packages/api/src/types.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-// Re-export types from @dispatch/core for convenience
-export type { AgentEvent, AgentStatus } from "@dispatch/core";
diff --git a/packages/api/src/wake-scheduler.ts b/packages/api/src/wake-scheduler.ts
deleted file mode 100644
index 8953e9f..0000000
--- a/packages/api/src/wake-scheduler.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-/**
- * Pure helpers for the Claude wake scheduler. Kept side-effect-free so the
- * recovery & rescheduling logic can be unit-tested without spinning up the
- * Hono app or touching SQLite.
- *
- * Semantics — read this before editing:
- *
- * 1. The user marks an hour (0-23) on the frontend. Marking the hour
- * schedules FOUR probes inside that hour, one per quarter-hour slot
- * (:00, :15, :30, :45). Each slot is its own persisted row keyed by
- * (hour, slot_minute). The frontend computes the *first* fire ms for
- * each slot in **its** local timezone and sends them; that absolute
- * ms is the source of truth.
- *
- * 2. After each fire (successful or not) we advance the slot by exactly
- * 24h from the previous `next_wake_at`. This preserves the user's
- * original local wall-clock intent regardless of the *server*'s
- * timezone. DST can drift the fire by ±1h on transition day; it
- * self-corrects the next time the user toggles the hour.
- *
- * 3. On server boot, any persisted slot whose `next_wake_at` is in the
- * past is "recovered": if it was missed by ≤ MISSED_WAKE_GRACE_MS we
- * fire it on the next tick (signal: `shouldFireNow = true`) and
- * advance to the next future occurrence. If missed by more than the
- * grace window we silently skip and advance. Either way the slot
- * stays scheduled.
- *
- * 4. Multiple slots that come due in the same tick (or recover at
- * boot) coalesce into a SINGLE upstream wake call. Probing four
- * times in 15 minutes is fine; probing four times within the same
- * 30s tick is wasteful and pointless.
- */
-
-/** How long after a missed fire we still consider it worth running. */
-export const MISSED_WAKE_GRACE_MS = 2 * 60 * 60 * 1000; // 2 hours
-
-/** Day length used when advancing recurring wakes. */
-export const DAILY_INTERVAL_MS = 24 * 60 * 60 * 1000;
-
-/** Fixed offset (hours) from a wake to the "Claude session reset" display. */
-export const CLAUDE_RESET_OFFSET_HOURS = 5;
-
-/** Minute offsets inside a marked hour where a probe fires. */
-export const PROBE_SLOT_MINUTES = [0, 15, 30, 45] as const;
-export type ProbeSlotMinute = (typeof PROBE_SLOT_MINUTES)[number];
-
-/**
- * Advance `previous` by 24-hour increments until strictly after `now`.
- * Pure: only does math on the given numbers.
- */
-export function nextDailyAfter(previous: number, now: number): number {
- if (previous > now) return previous;
- const deltaMs = now - previous;
- // Ceiling division so the result is strictly > now.
- const stepsAhead = Math.floor(deltaMs / DAILY_INTERVAL_MS) + 1;
- return previous + stepsAhead * DAILY_INTERVAL_MS;
-}
-
-export interface RecoveredEntry {
- /** New `next_wake_at` to persist (always strictly in the future). */
- nextWakeAt: number;
- /** True if the caller should fire a wake *right now* before scheduling. */
- shouldFireNow: boolean;
-}
-
-/**
- * Compute the post-boot state for a single persisted schedule entry.
- *
- * - Entry still in the future → keep as-is, no fire.
- * - Missed by ≤ grace window → fire now, then advance to next day.
- * - Missed by > grace window → skip the fire, advance to next day.
- */
-export function recoverScheduleEntry(
- storedNextWakeAt: number,
- now: number,
- graceMs: number = MISSED_WAKE_GRACE_MS,
-): RecoveredEntry {
- if (storedNextWakeAt > now) {
- return { nextWakeAt: storedNextWakeAt, shouldFireNow: false };
- }
- const overdueBy = now - storedNextWakeAt;
- const shouldFireNow = overdueBy <= graceMs;
- return {
- nextWakeAt: nextDailyAfter(storedNextWakeAt, now),
- shouldFireNow,
- };
-}
-
-/** Display hour (0-23) for the "reset" label paired with a wake hour. */
-export function resetHourFor(wakeHour: number): number {
- return (wakeHour + CLAUDE_RESET_OFFSET_HOURS) % 24;
-}
-
-/** Type guard: is this number a valid probe slot minute? */
-export function isProbeSlotMinute(n: unknown): n is ProbeSlotMinute {
- return n === 0 || n === 15 || n === 30 || n === 45;
-}
diff --git a/packages/api/tests/agent-manager.test.ts b/packages/api/tests/agent-manager.test.ts
deleted file mode 100644
index 7d8342e..0000000
--- a/packages/api/tests/agent-manager.test.ts
+++ /dev/null
@@ -1,2142 +0,0 @@
-import type { AgentEvent, ToolDefinition } from "@dispatch/core";
-import { beforeEach, describe, expect, it, vi } from "vitest";
-
-// Spy on appendEventToChunks so we can assert persistence calls
-const appendEventToChunksSpy = vi.fn((_chunks: unknown[], _event: unknown) => {
- // no-op; we inspect calls in tests
-});
-
-// Configurable stub for `getMessagesForTab`. Tests can push rows
-// before invoking `processMessage` to simulate prior conversation
-// history persisted in the DB (model-switch / history-replay path).
-interface FakeMessageRow {
- id: string;
- tabId: string;
- seq: number;
- role: "user" | "assistant" | "system";
- chunks: unknown[];
- createdAt: number;
-}
-const fakeMessagesByTab = new Map<string, FakeMessageRow[]>();
-function resetFakeMessages(): void {
- fakeMessagesByTab.clear();
-}
-function setFakeMessages(tabId: string, rows: FakeMessageRow[]): void {
- fakeMessagesByTab.set(tabId, rows);
-}
-
-// Configurable stub for the tabs DB (getTab / listOpenTabs). Tests can seed
-// rows to exercise deliverMessage cold-hydration and handle resolution.
-interface FakeTabRow {
- id: string;
- title: string;
- keyId: string | null;
- modelId: string | null;
- parentTabId: string | null;
- status: string;
- isOpen: boolean;
- position: number;
- createdAt: number;
- updatedAt: number;
-}
-const fakeTabs = new Map<string, FakeTabRow>();
-function resetFakeTabs(): void {
- fakeTabs.clear();
-}
-function setFakeTab(row: Partial<FakeTabRow> & { id: string }): void {
- fakeTabs.set(row.id, {
- title: "Tab",
- keyId: null,
- modelId: null,
- parentTabId: null,
- status: "idle",
- isOpen: true,
- position: 0,
- createdAt: 0,
- updatedAt: 0,
- ...row,
- });
-}
-function makeRow(
- tabId: string,
- seq: number,
- role: "user" | "assistant" | "system",
- chunks: unknown[],
-): FakeMessageRow {
- return { id: `msg-${tabId}-${seq}`, tabId, seq, role, chunks, createdAt: seq };
-}
-
-// Hook into Agent construction so tests can assert what
-// `messages` was pre-populated with at the moment `run()` was
-// called (after the post-construction pre-populate step in
-// `getOrCreateAgentForTab` has had a chance to assign).
-//
-// We snapshot at `run()` invocation rather than at construction
-// because the production code reassigns `agent.messages =
-// rows.slice(...)` AFTER `new Agent()` returns — capturing a
-// reference at construction would yield a stale empty array.
-const constructedAgents: Array<{
- initialMessages: unknown[];
- toolNames: string[];
- systemPrompt: string;
-}> = [];
-function resetConstructedAgents(): void {
- constructedAgents.length = 0;
-}
-
-// Capture the per-call `run()` options (notably reasoningEffort) so tests can
-// assert the per-model → per-tab → default effort resolution.
-const capturedRunOptions: Array<{ reasoningEffort?: string } | undefined> = [];
-function resetCapturedRunOptions(): void {
- capturedRunOptions.length = 0;
-}
-
-// Capture every warmCache(history) call so tests can assert the warming replay
-// receives the genuine (FULL) history and returns its usage unmodified.
-const capturedWarmHistories: unknown[][] = [];
-function resetCapturedWarmHistories(): void {
- capturedWarmHistories.length = 0;
-}
-
-// Configurable settings store so tests can toggle tool permissions
-// (perm_send_to_tab / perm_read_tab / ...) and assert which tools the
-// constructed Agent receives. Defaults to empty (getSetting → null).
-const fakeSettings = new Map<string, string>();
-function resetFakeSettings(): void {
- fakeSettings.clear();
-}
-function setFakeSetting(key: string, value: string): void {
- fakeSettings.set(key, value);
-}
-
-// Capture every appendChunks(tabId, drafts) call so tests can assert what got
-// persisted (e.g. usage side-channel rows). The real explodeTurn is mocked to
-// return [], so content drafts are empty here; usage rows are pushed directly
-// by processMessage's flushAssistant, making them the visible drafts.
-interface AppendChunksCall {
- tabId: string;
- drafts: Array<{ turnId: string; step: number; role: string; type: string; data: unknown }>;
-}
-const appendChunksCalls: AppendChunksCall[] = [];
-function resetAppendChunksCalls(): void {
- appendChunksCalls.length = 0;
-}
-
-// ── Compaction test scaffolding ────────────────────────────────────
-// Fake chunk store (per tab) feeding getChunksForTab / rekeyChunks.
-const fakeChunksByTab = new Map<string, Array<{ tabId: string }>>();
-// Records of createTab(id, title, opts) and rekeyChunks(from, to) calls.
-const createTabCalls: Array<{ id: string; title: string }> = [];
-const rekeyCalls: Array<{ from: string; to: string }> = [];
-// Configurable buildCompactionRequest result per source tab. Default: a
-// compactable conversation (non-empty prompt + a one-message tail).
-const fakeCompactionByTab = new Map<
- string,
- { prompt?: string; tail: Array<{ turnId: string; role: string; chunks: unknown[] }> }
->();
-// Configurable registry keys + env-key resolution so resolveConnection can
-// succeed in compaction tests. Default empty (preserves existing behaviour).
-const fakeRegistryKeys: Array<{
- id: string;
- provider: string;
- base_url: string;
- env?: string;
-}> = [];
-const fakeApiKeys = new Map<string, string>();
-const fakeConfigKeys: Array<{ id: string; provider: string; base_url: string; env?: string }> = [];
-function resetCompactionScaffolding(): void {
- fakeRegistryKeys.length = 0;
- fakeApiKeys.clear();
- fakeConfigKeys.length = 0;
- fakeChunksByTab.clear();
- createTabCalls.length = 0;
- rekeyCalls.length = 0;
- fakeCompactionByTab.clear();
-}
-
-// Seedable return value for the mocked getUsageStatsForTab — what the backend
-// reads (post-write) to attach to the `turn-sealed` event.
-const fakeUsageStatsByTab = new Map<string, unknown>();
-function resetFakeUsageStats(): void {
- fakeUsageStatsByTab.clear();
-}
-
-// Allow tests to swap in a custom `run` generator (e.g. to simulate
-// a fallback failure mid-stream). Returning to undefined restores
-// the default.
-type RunGen = (msg: string) => AsyncGenerator<unknown>;
-let runImpl: RunGen | null = null;
-function setRunImpl(impl: RunGen | null): void {
- runImpl = impl;
-}
-async function* defaultRun(_message: string): AsyncGenerator<unknown> {
- yield { type: "status", status: "running" } as const;
- await new Promise<void>((r) => setTimeout(r, 10));
- yield { type: "reasoning-delta", delta: "thinking about it" } as const;
- yield {
- type: "reasoning-end",
- metadata: { anthropic: { signature: "mock-sig" } },
- } as const;
- yield { type: "text-delta", delta: "Hello " } as const;
- yield { type: "text-delta", delta: "world" } as const;
- yield {
- type: "done",
- message: {
- role: "assistant",
- chunks: [
- {
- type: "thinking",
- text: "thinking about it",
- metadata: { anthropic: { signature: "mock-sig" } },
- },
- { type: "text", text: "Hello world" },
- ],
- },
- } as const;
- yield { type: "status", status: "idle" } as const;
-}
-
-// Mock @dispatch/core's Agent to avoid real LLM calls
-vi.mock("@dispatch/core", () => ({
- Agent: class MockAgent {
- status = "idle";
- messages: unknown[] = [];
- toolNames: string[] = [];
- systemPrompt = "";
- constructor(config: { tools?: Array<{ name: string }>; systemPrompt?: string }) {
- this.toolNames = (config?.tools ?? []).map((t) => t.name);
- this.systemPrompt = config?.systemPrompt ?? "";
- }
- async *run(message: string, options?: { reasoningEffort?: string }): AsyncGenerator<unknown> {
- // Snapshot the post-construction pre-populated message list
- // the first thing `run()` does, before the real `Agent.run`
- // would push the current user message at line 546. Tests
- // inspect this to verify history was loaded correctly.
- constructedAgents.push({
- initialMessages: [...this.messages],
- toolNames: [...this.toolNames],
- systemPrompt: this.systemPrompt,
- });
- capturedRunOptions.push(options);
- if (runImpl) {
- for await (const ev of runImpl(message)) yield ev;
- return;
- }
- for await (const ev of defaultRun(message)) yield ev;
- }
- async warmCache(history: unknown[]) {
- capturedWarmHistories.push([...history]);
- return {
- inputTokens: 1200,
- outputTokens: 1,
- cacheReadTokens: 1100,
- cacheWriteTokens: 0,
- };
- }
- },
- PermissionService: class MockPermissionService {
- ask(_request: unknown, _rulesets: unknown[]) {
- return Promise.resolve("once");
- }
- reply(_id: string, _reply: unknown) {}
- getPending() {
- return [];
- }
- },
- createReadFileTool(_wd: string): ToolDefinition {
- return {
- name: "read_file",
- description: "read a file",
- parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
- execute: async () => "mock file content",
- };
- },
- createReadFileSliceTool(_wd: string): ToolDefinition {
- return {
- name: "read_file_slice",
- description: "read a char slice of a single line",
- parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
- execute: async () => "mock slice",
- };
- },
- clearSpillForTab(_tabId: string) {},
- createWriteFileTool(_wd: string): ToolDefinition {
- return {
- name: "write_file",
- description: "write a file",
- parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
- execute: async () => true,
- };
- },
- createListFilesTool(_wd: string): ToolDefinition {
- return {
- name: "list_files",
- description: "list files",
- parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
- execute: async () => ["file1.ts"],
- };
- },
- createLspTool(_getContext: unknown): ToolDefinition {
- return {
- name: "lsp",
- description: "query the language server",
- parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
- execute: async () => "mock lsp",
- };
- },
- LspManager: class MockLspManager {
- hasServerForFile() {
- return false;
- }
- async getClients() {
- return [];
- }
- async touchFile() {}
- getDiagnostics() {
- return {};
- }
- async request() {
- return [];
- }
- async shutdownAll() {}
- },
- resolveServersFromConfig(_lsp: unknown) {
- return [];
- },
- reportDiagnostics(_file: string, _issues: unknown) {
- return "";
- },
- createRunShellTool(_wd: string): ToolDefinition {
- return {
- name: "run_shell",
- description: "run shell command",
- parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
- execute: async () => ({ stdout: "", stderr: "", exitCode: 0 }),
- };
- },
- loadConfig(_dir: string) {
- return fakeConfigKeys.length > 0
- ? { permissions: {}, keys: [...fakeConfigKeys] }
- : { permissions: {} };
- },
- configToRuleset(_config: unknown) {
- return [];
- },
- validateConfig(_config: unknown) {
- return { config: _config, errors: [] };
- },
- createConfigWatcher(_dir: string, _onChange: unknown) {
- return { close() {} };
- },
- watchDirConfig(_dir: string, _onChange: unknown) {
- return { close() {} };
- },
- loadSkills(_dir: string) {
- return { skills: [], mappings: [] };
- },
- createSkillsWatcher(_dir: string, _onChange: unknown) {
- return { close() {} };
- },
- ModelRegistry: class MockModelRegistry {
- getModels() {
- return [];
- }
- getKeys() {
- return fakeRegistryKeys.map((k) => ({ definition: k, status: "active" }));
- }
- getModelsByTag(_tag: string) {
- return [];
- }
- getAllTags() {
- return [];
- }
- hasAvailableKey(_provider: string) {
- return false;
- }
- allKeysExhausted() {
- return true;
- }
- markKeyExhausted() {}
- markKeyActive() {}
- updateConfig() {}
- },
- ModelResolver: class MockModelResolver {
- resolve(_tag: string) {
- return null;
- }
- waitForKey() {
- return Promise.resolve(null);
- }
- },
- TaskList: class MockTaskList {
- private tasks: Array<{ id: string; content: string; status: string }> = [];
- getTasks() {
- return this.tasks.map((t) => ({ ...t }));
- }
- setTasks(items: Array<{ content: string; status?: string }>) {
- this.tasks = items.map((item, i) => ({
- id: `task-${i + 1}`,
- content: item.content,
- status: item.status ?? "pending",
- }));
- return this.getTasks();
- }
- onChange(_cb: unknown) {
- return () => {};
- }
- },
- createTaskListTool(_taskList: unknown) {
- return {
- name: "todo",
- description: "todo",
- parameters: { _type: "z.ZodObject", shape: {} },
- execute: async () => "mock",
- };
- },
- createSummonTool(_wd: string, _callbacks: unknown) {
- return {
- name: "summon",
- description: "summon",
- parameters: { _type: "z.ZodObject", shape: {} },
- execute: async () => "mock",
- };
- },
- createRetrieveTool(_callbacks: unknown) {
- return {
- name: "retrieve",
- description: "retrieve",
- parameters: { _type: "z.ZodObject", shape: {} },
- execute: async () => "mock",
- };
- },
- // Summon parent-path dependencies. The real implementations load agent
- // definitions from disk; tests only need the summon/retrieve tool entries
- // to appear, so these return empty projections.
- loadAgents() {
- return [];
- },
- toAvailableSubagents() {
- return [];
- },
- toAvailableUserAgents() {
- return [];
- },
- getAgentDirPaths() {
- return [];
- },
- GLOBAL_AGENTS_DIR: "/tmp/global-agents",
- createTab(id: string, title: string) {
- createTabCalls.push({ id, title });
- return { id, title };
- },
- getTab(id: string) {
- return fakeTabs.get(id) ?? null;
- },
- listOpenTabs() {
- return [...fakeTabs.values()].filter((t) => t.isOpen);
- },
- resolveTabPrefix(prefix: string) {
- const sanitized = (prefix ?? "").toLowerCase().replace(/[^0-9a-f-]/g, "");
- if (sanitized.length < 4) return { status: "none" };
- const matches = [...fakeTabs.values()].filter(
- (t) => t.isOpen && t.id.toLowerCase().startsWith(sanitized),
- );
- if (matches.length === 0) return { status: "none" };
- if (matches.length === 1) return { status: "ok", tab: matches[0] };
- return { status: "ambiguous", matches };
- },
- shortestUniquePrefix(id: string) {
- return (id ?? "").slice(0, 4);
- },
- createSendToTabTool(_callbacks: unknown): ToolDefinition {
- return {
- name: "send_to_tab",
- description: "send to tab",
- parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
- execute: async () => "mock",
- };
- },
- createReadTabTool(_callbacks: unknown): ToolDefinition {
- return {
- name: "read_tab",
- description: "read tab",
- parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
- execute: async () => "mock",
- };
- },
- getClaudeAccountsFromDB() {
- return [];
- },
- refreshAccountCredentials() {
- return null;
- },
- refreshAccountCredentialsAsync() {
- return Promise.resolve(null);
- },
- resolveApiKey(keyId: string) {
- return fakeApiKeys.get(keyId) ?? null;
- },
- getSetting(key: string) {
- return fakeSettings.get(key) ?? null;
- },
- isReasoningEffort(value: unknown) {
- return (
- typeof value === "string" && ["none", "low", "medium", "high", "xhigh", "max"].includes(value)
- );
- },
- appendChunks(tabId: string, drafts: AppendChunksCall["drafts"]) {
- appendChunksCalls.push({ tabId, drafts: [...drafts] });
- return [];
- },
- explodeUserText() {
- return [];
- },
- explodeTurn() {
- return [{ turnId: "t", step: 0, role: "assistant", type: "text", data: { text: "" } }];
- },
- getChunksForTab(tabId: string) {
- return fakeChunksByTab.get(tabId) ?? [];
- },
- groupRowsToMessages(rows: Array<{ tabId: string }>) {
- return rows;
- },
- rekeyChunks(from: string, to: string) {
- rekeyCalls.push({ from, to });
- const rows = fakeChunksByTab.get(from) ?? [];
- fakeChunksByTab.set(to, rows);
- fakeChunksByTab.delete(from);
- return rows.length;
- },
- buildCompactionRequest(input: { messages: unknown[] }) {
- // Resolve the seeded result by matching the first row's tabId.
- const first = (input.messages as Array<{ tabId?: string }>)[0];
- const tabId = first?.tabId ?? "";
- const seeded = fakeCompactionByTab.get(tabId);
- if (seeded) return { head: [], tail: seeded.tail, prompt: seeded.prompt };
- return {
- head: [],
- tail: [{ turnId: "tail-turn", role: "user", chunks: [{ type: "text", text: "recent" }] }],
- prompt: "SUMMARY PROMPT",
- };
- },
- buildSummaryTurnText(summary: string) {
- return `[CONVERSATION SUMMARY]\n\n${summary}`;
- },
- getMessagesForTab(tabId: string) {
- return fakeMessagesByTab.get(tabId) ?? [];
- },
- getUsageStatsForTab(tabId: string) {
- return fakeUsageStatsByTab.get(tabId) ?? null;
- },
- appendEventToChunks: appendEventToChunksSpy,
- applySystemEvent(_messages: unknown[], _event: unknown) {
- return { messageId: "mock-system-msg" };
- },
- BackgroundShellStore: class MockBackgroundShellStore {
- has() {
- return false;
- }
- getResult() {
- return Promise.resolve({ status: "error", error: "not found" });
- }
- },
- BackgroundTranscriptStore: class MockBackgroundTranscriptStore {
- has() {
- return false;
- }
- getResult() {
- return Promise.resolve({ status: "error", error: "not found" });
- }
- },
- createWebSearchTool() {
- return {
- name: "web_search",
- description: "web search",
- parameters: { _type: "z.ZodObject", shape: {} },
- execute: async () => "mock",
- };
- },
- createKeyUsageTool(_callbacks: unknown) {
- return {
- name: "key_usage",
- description: "key usage",
- parameters: { _type: "z.ZodObject", shape: {} },
- execute: async () => "mock",
- };
- },
- createSearchCodeTool(_wd: string) {
- return {
- name: "search_code",
- description: "search code",
- parameters: { _type: "z.ZodObject", shape: {} },
- execute: async () => "mock",
- };
- },
- createYoutubeTranscribeTool() {
- return {
- name: "youtube_transcribe",
- description: "youtube transcribe",
- parameters: { _type: "z.ZodObject", shape: {} },
- execute: async () => "mock",
- };
- },
-}));
-
-// Import after mock is defined (Vitest hoists vi.mock automatically)
-const { AgentManager } = await import("../src/agent-manager.js");
-
-describe("AgentManager", () => {
- beforeEach(() => {
- resetFakeMessages();
- resetConstructedAgents();
- resetCapturedRunOptions();
- resetFakeTabs();
- resetFakeSettings();
- setRunImpl(null);
- appendEventToChunksSpy.mockClear();
- resetAppendChunksCalls();
- resetFakeUsageStats();
- resetCapturedWarmHistories();
- resetCompactionScaffolding();
- });
-
- it("initial status is idle", () => {
- const manager = new AgentManager();
- expect(manager.getStatus()).toBe("idle");
- });
-
- it("initial messageCount is 0", () => {
- const manager = new AgentManager();
- expect(manager.getMessageCount()).toBe(0);
- });
-
- it("event listeners receive events during processMessage", async () => {
- const manager = new AgentManager();
- const events: AgentEvent[] = [];
- manager.onEvent((event) => {
- events.push(event);
- });
-
- await manager.processMessage("tab-1", "test");
-
- expect(events.length).toBeGreaterThan(0);
- // A turn now opens with `turn-start`, immediately followed by the
- // agent's `status: running`.
- expect(events[0]).toMatchObject({ type: "turn-start" });
- expect(events[1]).toMatchObject({ type: "status", status: "running" });
-
- // A turn now closes with `turn-sealed` (emitted after the DB write, which
- // is after the agent's final `status: idle`).
- const lastEvent = events[events.length - 1];
- expect(lastEvent).toMatchObject({ type: "turn-sealed" });
- expect(events.some((e) => e.type === "status" && e.status === "idle")).toBe(true);
-
- const doneEvent = events.find((e) => e.type === "done");
- expect(doneEvent).toBeDefined();
- });
-
- it("emits a turn-start with a turnId before any content event", async () => {
- const manager = new AgentManager();
- const events: AgentEvent[] = [];
- manager.onEvent((event) => {
- events.push(event);
- });
-
- await manager.processMessage("tab-turnstart", "go");
-
- const turnStartIdx = events.findIndex((e) => e.type === "turn-start");
- expect(turnStartIdx).toBeGreaterThanOrEqual(0);
- const turnStart = events[turnStartIdx] as Extract<AgentEvent, { type: "turn-start" }>;
- expect(typeof turnStart.turnId).toBe("string");
- expect(turnStart.turnId.length).toBeGreaterThan(0);
-
- // Must precede the first content delta.
- const firstContentIdx = events.findIndex(
- (e) => e.type === "text-delta" || e.type === "reasoning-delta",
- );
- expect(firstContentIdx).toBeGreaterThan(turnStartIdx);
- });
-
- it("emits text-delta events during processMessage", async () => {
- const manager = new AgentManager();
- const events: AgentEvent[] = [];
- manager.onEvent((event) => {
- events.push(event);
- });
-
- await manager.processMessage("tab-1", "hello");
-
- const textDeltas = events.filter((e) => e.type === "text-delta");
- expect(textDeltas.length).toBeGreaterThan(0);
- });
-
- it("messageCount increments after processMessage", async () => {
- const manager = new AgentManager();
- await manager.processMessage("tab-1", "hello");
- expect(manager.getMessageCount()).toBe(1);
- await manager.processMessage("tab-1", "world");
- expect(manager.getMessageCount()).toBe(2);
- });
-
- it("status returns to idle after processMessage completes", async () => {
- const manager = new AgentManager();
- await manager.processMessage("tab-1", "test");
- expect(manager.getStatus()).toBe("idle");
- });
-
- it("unsubscribe removes listener", async () => {
- const manager = new AgentManager();
- const events: AgentEvent[] = [];
- const unsubscribe = manager.onEvent((event) => {
- events.push(event);
- });
-
- unsubscribe();
- await manager.processMessage("tab-1", "test");
-
- expect(events.length).toBe(0);
- });
-
- it("multiple listeners all receive events", async () => {
- const manager = new AgentManager();
- const listener1 = vi.fn();
- const listener2 = vi.fn();
-
- manager.onEvent(listener1);
- manager.onEvent(listener2);
-
- await manager.processMessage("tab-1", "test");
-
- expect(listener1).toHaveBeenCalled();
- expect(listener2).toHaveBeenCalled();
- });
-
- // ─── per-model reasoning effort precedence ───────────────────────
-
- describe("reasoning effort precedence (per-model → per-tab → default)", () => {
- it("uses the per-model effort over the per-tab selector for that fallback entry", async () => {
- const manager = new AgentManager();
- // Agent definition supplies a fallback chain where each entry has its
- // own configured effort; the per-tab selector ("low") must NOT win.
- await manager.processMessage(
- "tab-effort-permodel",
- "go",
- "key-a",
- "model-a",
- "low",
- undefined,
- [{ key_id: "key-a", model_id: "model-a", effort: "xhigh" }],
- );
- expect(capturedRunOptions.at(-1)?.reasoningEffort).toBe("xhigh");
- });
-
- it("falls back to the per-tab selector when the model entry has no effort", async () => {
- const manager = new AgentManager();
- await manager.processMessage(
- "tab-effort-tab",
- "go",
- "key-a",
- "model-a",
- "medium",
- undefined,
- [{ key_id: "key-a", model_id: "model-a" }],
- );
- expect(capturedRunOptions.at(-1)?.reasoningEffort).toBe("medium");
- });
-
- it("passes no effort (Agent applies its default) when neither is set", async () => {
- const manager = new AgentManager();
- await manager.processMessage(
- "tab-effort-default",
- "go",
- "key-a",
- "model-a",
- undefined,
- undefined,
- [{ key_id: "key-a", model_id: "model-a" }],
- );
- expect(capturedRunOptions.at(-1)?.reasoningEffort).toBeUndefined();
- });
- });
-
- // ─── v6 reasoning-end tests ───────────────────────────────────────
-
- it("reasoning-end event is broadcast to WS listeners", async () => {
- const manager = new AgentManager();
- const events: AgentEvent[] = [];
- manager.onEvent((event) => {
- events.push(event);
- });
-
- await manager.processMessage("tab-reasoning", "think please");
-
- const reasoningEndEvents = events.filter((e) => e.type === "reasoning-end");
- expect(reasoningEndEvents.length).toBeGreaterThan(0);
- expect(reasoningEndEvents[0]).toMatchObject({
- type: "reasoning-end",
- metadata: { anthropic: { signature: "mock-sig" } },
- });
- });
-
- it("reasoning-end is passed to appendEventToChunks for persistence", async () => {
- appendEventToChunksSpy.mockClear();
- const manager = new AgentManager();
-
- await manager.processMessage("tab-persist", "think and persist");
-
- // Find all calls to appendEventToChunks that received a reasoning-end event
- const reasoningEndCalls = appendEventToChunksSpy.mock.calls.filter(
- ([_chunks, event]) => (event as AgentEvent).type === "reasoning-end",
- );
- expect(reasoningEndCalls.length).toBeGreaterThan(0);
-
- // The event should carry the metadata blob
- const [, reasoningEndEvent] = reasoningEndCalls[0] as [unknown[], AgentEvent];
- expect(reasoningEndEvent).toMatchObject({
- type: "reasoning-end",
- metadata: { anthropic: { signature: "mock-sig" } },
- });
- });
-
- it("reasoning-end follows reasoning-delta in broadcast order (chunk accumulator ordering)", async () => {
- const manager = new AgentManager();
- const events: AgentEvent[] = [];
- manager.onEvent((event) => {
- events.push(event);
- });
-
- await manager.processMessage("tab-ordering", "think in order");
-
- const types = events.map((e) => e.type);
- const deltaIdx = types.indexOf("reasoning-delta");
- const endIdx = types.indexOf("reasoning-end");
-
- // Both must be present
- expect(deltaIdx).toBeGreaterThanOrEqual(0);
- expect(endIdx).toBeGreaterThanOrEqual(0);
-
- // reasoning-end must come AFTER reasoning-delta
- expect(endIdx).toBeGreaterThan(deltaIdx);
-
- // reasoning-end must come BEFORE any text-delta (reasoning precedes text)
- const textDeltaIdx = types.indexOf("text-delta");
- if (textDeltaIdx >= 0) {
- expect(endIdx).toBeLessThan(textDeltaIdx);
- }
- });
-
- it("done event includes a thinking chunk with metadata in its message", async () => {
- const manager = new AgentManager();
- const events: AgentEvent[] = [];
- manager.onEvent((event) => {
- events.push(event);
- });
-
- await manager.processMessage("tab-done-chunks", "think and respond");
-
- const doneEvent = events.find((e) => e.type === "done") as
- | Extract<AgentEvent, { type: "done" }>
- | undefined;
- expect(doneEvent).toBeDefined();
-
- const thinkingChunk = doneEvent?.message.chunks.find((c) => c.type === "thinking");
- expect(thinkingChunk).toBeDefined();
- expect(thinkingChunk).toMatchObject({
- type: "thinking",
- text: "thinking about it",
- metadata: { anthropic: { signature: "mock-sig" } },
- });
- });
-
- // ─── History pre-population on Agent (re)construction ────────────
- //
- // These tests guard the fix that prior conversation turns survive
- // switching models mid-conversation via the sidebar slider. Without
- // it, a fresh `Agent` is constructed with `messages: []` and the
- // next LLM call sees zero prior context.
-
- it("pre-populates Agent.messages from DB history when constructing a fresh Agent", async () => {
- const manager = new AgentManager();
- const tabId = "tab-history";
-
- // Simulate prior conversation in the DB:
- // u1, a1, u_current
- // (the current turn's user message has already been appended
- // by `processMessage` before `getOrCreateAgentForTab` runs)
- setFakeMessages(tabId, [
- makeRow(tabId, 0, "user", [{ type: "text", text: "first question" }]),
- makeRow(tabId, 1, "assistant", [{ type: "text", text: "first answer" }]),
- makeRow(tabId, 2, "user", [{ type: "text", text: "follow-up" }]),
- ]);
-
- await manager.processMessage(tabId, "follow-up");
-
- // Exactly one Agent should have been constructed for this tab,
- // and its messages must be the prior two rows (excluding the
- // current user message — `Agent.run()` pushes that itself).
- expect(constructedAgents.length).toBe(1);
- const inst = constructedAgents[0];
- expect(inst).toBeDefined();
- if (!inst) return;
- const init = inst.initialMessages as Array<{ role: string; chunks: unknown[] }>;
- expect(init.length).toBe(2);
- expect(init[0]).toMatchObject({
- role: "user",
- chunks: [{ type: "text", text: "first question" }],
- });
- expect(init[1]).toMatchObject({
- role: "assistant",
- chunks: [{ type: "text", text: "first answer" }],
- });
- });
-
- it("leaves messages empty when the DB has only the current turn's user message (first turn)", async () => {
- const manager = new AgentManager();
- const tabId = "tab-first-turn";
-
- // First-ever turn: DB has only the just-appended user message.
- setFakeMessages(tabId, [makeRow(tabId, 0, "user", [{ type: "text", text: "hello" }])]);
-
- await manager.processMessage(tabId, "hello");
-
- expect(constructedAgents.length).toBe(1);
- const inst = constructedAgents[0];
- expect(inst).toBeDefined();
- if (!inst) return;
- // The user message at idx 0 is the current turn — must be excluded.
- expect((inst.initialMessages as unknown[]).length).toBe(0);
- });
-
- it("excludes a partial assistant trail from a prior fallback attempt", async () => {
- const manager = new AgentManager();
- const tabId = "tab-fallback-partial";
-
- // Scenario: the agent-mode fallback path. Attempt 1 (Opus) errored
- // mid-stream after flushing some chunks; attempt 2 (DeepSeek) is
- // about to start. DB looks like:
- // u1, a1, u_current, partial_a_attempt1
- // The fresh Agent for attempt 2 must see [u1, a1] — not the
- // current user message and not the failed attempt's partial.
- setFakeMessages(tabId, [
- makeRow(tabId, 0, "user", [{ type: "text", text: "q1" }]),
- makeRow(tabId, 1, "assistant", [{ type: "text", text: "a1" }]),
- makeRow(tabId, 2, "user", [{ type: "text", text: "q2" }]),
- makeRow(tabId, 3, "assistant", [{ type: "text", text: "half-baked..." }]),
- ]);
-
- await manager.processMessage(tabId, "q2");
-
- expect(constructedAgents.length).toBe(1);
- const inst = constructedAgents[0];
- expect(inst).toBeDefined();
- if (!inst) return;
- const init = inst.initialMessages as Array<{ role: string; chunks: unknown[] }>;
- expect(init.length).toBe(2);
- expect(init[0]).toMatchObject({ role: "user", chunks: [{ type: "text", text: "q1" }] });
- expect(init[1]).toMatchObject({ role: "assistant", chunks: [{ type: "text", text: "a1" }] });
- });
-
- it("preserves system-role rows in pre-populated history (toModelMessages filters them later)", async () => {
- const manager = new AgentManager();
- const tabId = "tab-with-system-rows";
-
- setFakeMessages(tabId, [
- makeRow(tabId, 0, "user", [{ type: "text", text: "q1" }]),
- makeRow(tabId, 1, "assistant", [{ type: "text", text: "a1" }]),
- makeRow(tabId, 2, "system", [
- { type: "system", kind: "config-reload", text: "Configuration reloaded" },
- ]),
- makeRow(tabId, 3, "user", [{ type: "text", text: "q2" }]),
- ]);
-
- await manager.processMessage(tabId, "q2");
-
- expect(constructedAgents.length).toBe(1);
- const inst = constructedAgents[0];
- expect(inst).toBeDefined();
- if (!inst) return;
- const init = inst.initialMessages as Array<{ role: string; chunks: unknown[] }>;
- // All three prior rows (user/assistant/system) preserved; the
- // LLM-facing `toModelMessages` strips the system row later.
- expect(init.length).toBe(3);
- expect(init[2]).toMatchObject({ role: "system" });
- });
-
- it("survives a getMessagesForTab failure without crashing (messages stays empty)", async () => {
- const manager = new AgentManager();
- const tabId = "tab-db-error";
-
- // Simulate DB error by stubbing the fake-store getter to throw
- // for this specific tab. We use a Proxy on the Map's get method
- // for the duration of one call.
- const realGet = fakeMessagesByTab.get.bind(fakeMessagesByTab);
- fakeMessagesByTab.get = ((key: string) => {
- if (key === tabId) throw new Error("simulated DB error");
- return realGet(key);
- }) as typeof fakeMessagesByTab.get;
-
- try {
- await expect(manager.processMessage(tabId, "anything")).resolves.toBeUndefined();
- } finally {
- fakeMessagesByTab.get = realGet;
- }
-
- // Agent still constructed, just with empty messages.
- expect(constructedAgents.length).toBe(1);
- const inst = constructedAgents[0];
- expect(inst).toBeDefined();
- if (!inst) return;
- expect((inst.initialMessages as unknown[]).length).toBe(0);
- });
-
- it("reloads history on every Agent reconstruction (simulated model switch)", async () => {
- const manager = new AgentManager();
- const tabId = "tab-model-switch";
-
- // Turn 1: empty DB → just the first user message.
- setFakeMessages(tabId, [makeRow(tabId, 0, "user", [{ type: "text", text: "q1" }])]);
- await manager.processMessage(tabId, "q1", "key-opus", "claude-opus-4-7");
-
- // Turn 2: DB now has the full prior turn + new user message.
- // User has switched models via the sidebar slider — different
- // (keyId, modelId) triggers Agent invalidation and reconstruction.
- setFakeMessages(tabId, [
- makeRow(tabId, 0, "user", [{ type: "text", text: "q1" }]),
- makeRow(tabId, 1, "assistant", [{ type: "text", text: "a1" }]),
- makeRow(tabId, 2, "user", [{ type: "text", text: "q2" }]),
- ]);
- await manager.processMessage(tabId, "q2", "key-deepseek", "deepseek-v3");
-
- // Exactly two Agents constructed across the two turns (the
- // invalidation gate fires when keyId/modelId change).
- expect(constructedAgents.length).toBe(2);
-
- // Second Agent (the DeepSeek one) was pre-populated with the
- // completed first turn — not empty, not duplicating q2.
- const second = constructedAgents[1];
- expect(second).toBeDefined();
- if (!second) return;
- const init = second.initialMessages as Array<{ role: string; chunks: unknown[] }>;
- expect(init.length).toBe(2);
- expect(init[0]).toMatchObject({ role: "user", chunks: [{ type: "text", text: "q1" }] });
- expect(init[1]).toMatchObject({ role: "assistant", chunks: [{ type: "text", text: "a1" }] });
- });
-
- // ─── getAllStatuses snapshot shape (for browser-reopen restore) ────
- //
- // The snapshot enriches the legacy `Record<string, AgentStatus>` shape
- // with per-tab in-flight context so a fresh frontend can render the
- // streaming assistant message correctly after a reload.
-
- it("getAllStatuses returns an empty record when no tabs are tracked", () => {
- const manager = new AgentManager();
- expect(manager.getAllStatuses()).toEqual({});
- });
-
- it("getAllStatuses returns { status } for an idle tab (no currentChunks/currentAssistantId)", async () => {
- const manager = new AgentManager();
- // Drive a full turn so the tab gets registered; default mock run
- // settles back to idle by the time `await` resolves.
- await manager.processMessage("tab-idle", "hi");
- const snap = manager.getAllStatuses();
- expect(snap["tab-idle"]).toBeDefined();
- expect(snap["tab-idle"]?.status).toBe("idle");
- expect(snap["tab-idle"]).not.toHaveProperty("currentChunks");
- expect(snap["tab-idle"]).not.toHaveProperty("currentAssistantId");
- });
-
- it("getAllStatuses includes currentChunks and currentAssistantId for a running tab", () => {
- const manager = new AgentManager();
- // Reach into the private map to set up a synthetic running state.
- // Justification: there is no public API to enter a sustained
- // "running" state without actually streaming, and we want to
- // assert the snapshot shape — not the streaming pipeline.
- const inner = manager as unknown as {
- tabAgents: Map<
- string,
- {
- agent: null;
- status: "running" | "idle" | "error";
- keyId: null;
- modelId: null;
- taskList: { onChange: (cb: unknown) => void; getTasks: () => unknown[] };
- messageQueue: unknown[];
- queueListeners: unknown[];
- shellStore: unknown;
- transcriptStore: unknown;
- currentChunks: Array<{ type: string; text?: string }> | null;
- currentAssistantId: string | null;
- }
- >;
- };
- inner.tabAgents.set("tab-running", {
- agent: null,
- status: "running",
- keyId: null,
- modelId: null,
- taskList: { onChange: () => {}, getTasks: () => [] },
- messageQueue: [],
- queueListeners: [],
- shellStore: {},
- transcriptStore: {},
- currentChunks: [
- { type: "thinking", text: "let me think" },
- { type: "text", text: "partial answer" },
- ],
- currentAssistantId: "assistant-msg-id-7",
- });
-
- const snap = manager.getAllStatuses();
- expect(snap["tab-running"]).toBeDefined();
- expect(snap["tab-running"]?.status).toBe("running");
- expect(snap["tab-running"]?.currentAssistantId).toBe("assistant-msg-id-7");
- expect(snap["tab-running"]?.currentChunks).toEqual([
- { type: "thinking", text: "let me think" },
- { type: "text", text: "partial answer" },
- ]);
- });
-
- it("getAllStatuses defensively copies currentChunks (mutating the snapshot doesn't affect the live array)", () => {
- const manager = new AgentManager();
- const inner = manager as unknown as {
- tabAgents: Map<
- string,
- {
- agent: null;
- status: "running";
- keyId: null;
- modelId: null;
- taskList: { onChange: (cb: unknown) => void; getTasks: () => unknown[] };
- messageQueue: unknown[];
- queueListeners: unknown[];
- shellStore: unknown;
- transcriptStore: unknown;
- currentChunks: Array<{ type: string; text?: string }>;
- currentAssistantId: string;
- }
- >;
- };
- const liveChunks = [{ type: "text", text: "live" }];
- inner.tabAgents.set("tab-copy", {
- agent: null,
- status: "running",
- keyId: null,
- modelId: null,
- taskList: { onChange: () => {}, getTasks: () => [] },
- messageQueue: [],
- queueListeners: [],
- shellStore: {},
- transcriptStore: {},
- currentChunks: liveChunks,
- currentAssistantId: "msg-x",
- });
-
- const snap = manager.getAllStatuses();
- // Mutate the snapshot's array
- snap["tab-copy"]?.currentChunks?.push({ type: "text", text: "polluted" });
- // Live array must be untouched
- expect(liveChunks).toEqual([{ type: "text", text: "live" }]);
- });
-
- it("getAllStatuses omits currentChunks when a running tab has none yet", () => {
- const manager = new AgentManager();
- const inner = manager as unknown as {
- tabAgents: Map<
- string,
- {
- agent: null;
- status: "running";
- keyId: null;
- modelId: null;
- taskList: { onChange: (cb: unknown) => void; getTasks: () => unknown[] };
- messageQueue: unknown[];
- queueListeners: unknown[];
- shellStore: unknown;
- transcriptStore: unknown;
- currentChunks: null;
- currentAssistantId: null;
- }
- >;
- };
- inner.tabAgents.set("tab-early", {
- agent: null,
- status: "running",
- keyId: null,
- modelId: null,
- taskList: { onChange: () => {}, getTasks: () => [] },
- messageQueue: [],
- queueListeners: [],
- shellStore: {},
- transcriptStore: {},
- currentChunks: null,
- currentAssistantId: null,
- });
-
- const snap = manager.getAllStatuses();
- expect(snap["tab-early"]?.status).toBe("running");
- expect(snap["tab-early"]).not.toHaveProperty("currentChunks");
- expect(snap["tab-early"]).not.toHaveProperty("currentAssistantId");
- });
-
- it("getAllStatuses includes a tab's todo list (for reload rehydration)", () => {
- const manager = new AgentManager();
- // Public API: getTaskList creates+returns the tab's list. setTasks is
- // the declarative whole-list write.
- const list = manager.getTaskList("tab-todos");
- list.setTasks([
- { content: "plan", status: "completed" },
- { content: "build", status: "in_progress" },
- ]);
- const snap = manager.getAllStatuses();
- expect(snap["tab-todos"]?.tasks).toEqual([
- { id: "task-1", content: "plan", status: "completed" },
- { id: "task-2", content: "build", status: "in_progress" },
- ]);
- });
-
- it("getAllStatuses omits tasks for a tab with an empty todo list", () => {
- const manager = new AgentManager();
- manager.getTaskList("tab-empty");
- const snap = manager.getAllStatuses();
- expect(snap["tab-empty"]).toBeDefined();
- expect(snap["tab-empty"]).not.toHaveProperty("tasks");
- });
-
- // ─── Tab-to-tab communication ─────────────────────────────────
-
- describe("deliverMessage", () => {
- it("starts a new turn when the target tab is idle", async () => {
- const manager = new AgentManager();
- const events: AgentEvent[] = [];
- manager.onEvent((e) => events.push(e));
-
- const outcome = manager.deliverMessage("tab-idle", "wake up");
- expect(outcome.status).toBe("started");
-
- // Let the background turn run to completion.
- await new Promise<void>((r) => setTimeout(r, 60));
- expect(events.some((e) => e.type === "text-delta")).toBe(true);
- expect(manager.getTabStatus("tab-idle")).toBe("idle");
- });
-
- it("queues the message when the target tab is running", () => {
- const manager = new AgentManager();
- const inner = manager as unknown as {
- tabAgents: Map<string, Record<string, unknown>>;
- };
- // Seed a running tab agent directly.
- inner.tabAgents.set("tab-busy", {
- agent: null,
- status: "running",
- keyId: null,
- modelId: null,
- taskList: { onChange: () => {}, getTasks: () => [] },
- messageQueue: [],
- queueListeners: [],
- shellStore: {},
- transcriptStore: {},
- currentChunks: null,
- currentAssistantId: null,
- currentTurnId: null,
- });
-
- const outcome = manager.deliverMessage("tab-busy", "queued msg");
- expect(outcome.status).toBe("queued");
- if (outcome.status === "queued") {
- expect(typeof outcome.messageId).toBe("string");
- }
- // The message landed on the running tab's queue.
- const agent = inner.tabAgents.get("tab-busy") as { messageQueue: unknown[] };
- expect(agent.messageQueue).toHaveLength(1);
- });
-
- it("hydrates key/model from the persisted tab row for a cold wake", () => {
- const manager = new AgentManager();
- setFakeTab({ id: "tab-cold", keyId: "persisted-key", modelId: "persisted-model" });
-
- // Spy on processMessage to capture the key/model deliverMessage
- // forwarded — asserting the hydration decision directly rather than
- // downstream tabAgent state (which the mocked ModelRegistry rewrites).
- const spy = vi.spyOn(manager, "processMessage").mockResolvedValue(undefined);
-
- const outcome = manager.deliverMessage("tab-cold", "hello");
- expect(outcome.status).toBe("started");
-
- expect(spy).toHaveBeenCalledTimes(1);
- const args = spy.mock.calls[0] ?? [];
- expect(args[0]).toBe("tab-cold"); // tabId
- expect(args[1]).toBe("hello"); // message
- expect(args[2]).toBe("persisted-key"); // keyId hydrated from row
- expect(args[3]).toBe("persisted-model"); // modelId hydrated from row
- });
-
- it("prefers explicit opts over the persisted row on a cold wake", () => {
- const manager = new AgentManager();
- setFakeTab({ id: "tab-cold2", keyId: "row-key", modelId: "row-model" });
- const spy = vi.spyOn(manager, "processMessage").mockResolvedValue(undefined);
-
- manager.deliverMessage("tab-cold2", "hello", {
- keyId: "explicit-key",
- modelId: "explicit-model",
- });
-
- const args = spy.mock.calls[0] ?? [];
- expect(args[2]).toBe("explicit-key");
- expect(args[3]).toBe("explicit-model");
- });
- });
-
- describe("deliverMessage — agent auto-wake budget", () => {
- it("allows up to 6 consecutive agent wakes, then suppresses further ones", () => {
- const manager = new AgentManager();
- const spy = vi.spyOn(manager, "processMessage").mockResolvedValue(undefined);
-
- // 6 agent-originated wakes of an idle tab should all start turns.
- for (let i = 0; i < 6; i++) {
- const outcome = manager.deliverMessage("tab-pp", `msg ${i}`, { origin: "agent" });
- expect(outcome.status).toBe("started");
- }
- expect(spy).toHaveBeenCalledTimes(6);
-
- // The 7th is suppressed: no new turn, message preserved on the queue.
- const seventh = manager.deliverMessage("tab-pp", "msg 7", { origin: "agent" });
- expect(seventh.status).toBe("suppressed");
- expect(spy).toHaveBeenCalledTimes(6); // unchanged — no wake
-
- const inner = manager as unknown as {
- tabAgents: Map<string, { messageQueue: unknown[]; autoWakeBudget: number }>;
- };
- const agent = inner.tabAgents.get("tab-pp");
- expect(agent?.autoWakeBudget).toBe(0);
- // Suppressed message is queued, not dropped.
- expect(agent?.messageQueue).toHaveLength(1);
- });
-
- it("a human message refills the budget and re-enables agent wakes", () => {
- const manager = new AgentManager();
- vi.spyOn(manager, "processMessage").mockResolvedValue(undefined);
-
- // Exhaust the budget with agent wakes.
- for (let i = 0; i < 6; i++) {
- manager.deliverMessage("tab-refill", `a${i}`, { origin: "agent" });
- }
- expect(manager.deliverMessage("tab-refill", "blocked", { origin: "agent" }).status).toBe(
- "suppressed",
- );
-
- // A human message refills the budget...
- const humanOutcome = manager.deliverMessage("tab-refill", "human here", {
- origin: "human",
- });
- expect(humanOutcome.status).toBe("started");
-
- const inner = manager as unknown as {
- tabAgents: Map<string, { autoWakeBudget: number }>;
- };
- expect(inner.tabAgents.get("tab-refill")?.autoWakeBudget).toBe(6);
-
- // ...so an agent can wake it again.
- expect(manager.deliverMessage("tab-refill", "again", { origin: "agent" }).status).toBe(
- "started",
- );
- });
-
- it("does not consume budget when the message is merely queued (busy target)", () => {
- const manager = new AgentManager();
- const inner = manager as unknown as {
- tabAgents: Map<string, Record<string, unknown>>;
- };
- inner.tabAgents.set("tab-busy-budget", {
- agent: null,
- status: "running",
- keyId: null,
- modelId: null,
- taskList: { onChange: () => {}, getTasks: () => [] },
- messageQueue: [],
- queueListeners: [],
- shellStore: {},
- transcriptStore: {},
- currentChunks: null,
- currentAssistantId: null,
- currentTurnId: null,
- autoWakeBudget: 6,
- });
-
- const outcome = manager.deliverMessage("tab-busy-budget", "queued one", {
- origin: "agent",
- });
- expect(outcome.status).toBe("queued");
- // Budget untouched — queuing can't drive a runaway loop.
- const agent = inner.tabAgents.get("tab-busy-budget") as { autoWakeBudget: number };
- expect(agent.autoWakeBudget).toBe(6);
- });
-
- it("human-originated wakes are never throttled", () => {
- const manager = new AgentManager();
- const spy = vi.spyOn(manager, "processMessage").mockResolvedValue(undefined);
-
- // Far more than the budget, all human-originated → all start turns.
- for (let i = 0; i < 10; i++) {
- const outcome = manager.deliverMessage("tab-human", `h${i}`, { origin: "human" });
- expect(outcome.status).toBe("started");
- }
- expect(spy).toHaveBeenCalledTimes(10);
- });
-
- it("defaults origin to human when unspecified (POST /chat path)", () => {
- const manager = new AgentManager();
- const spy = vi.spyOn(manager, "processMessage").mockResolvedValue(undefined);
- for (let i = 0; i < 8; i++) {
- expect(manager.deliverMessage("tab-default", `d${i}`).status).toBe("started");
- }
- expect(spy).toHaveBeenCalledTimes(8);
- });
- });
-
- describe("queue continuation after a turn ends", () => {
- // A run generator that enqueues `msg` (as if a user/agent sent it mid-turn)
- // exactly once, then streams a normal short reply. Used to simulate a
- // message landing on the queue while the agent is busy.
- function runThatEnqueues(manager: AgentManager, tabId: string, msg: string): RunGen {
- let enqueued = false;
- return async function* () {
- yield { type: "status", status: "running" } as const;
- if (!enqueued) {
- enqueued = true;
- manager.queueMessage(tabId, msg);
- }
- yield { type: "text-delta", delta: "reply" } as const;
- yield {
- type: "done",
- message: { role: "assistant", chunks: [{ type: "text", text: "reply" }] },
- } as const;
- yield { type: "status", status: "idle" } as const;
- };
- }
-
- it("starts a NEW turn for a message queued during the turn (the bug fix)", async () => {
- const manager = new AgentManager();
- const processSpy = vi.spyOn(manager, "processMessage");
- setRunImpl(runThatEnqueues(manager, "tab-cont", "follow-up question"));
-
- await manager.processMessage("tab-cont", "first");
- // Let the fire-and-forget continuation turn run to completion.
- await new Promise<void>((r) => setTimeout(r, 50));
-
- // processMessage called twice: the original turn + the continuation.
- expect(processSpy).toHaveBeenCalledTimes(2);
- expect(processSpy.mock.calls[1]?.[0]).toBe("tab-cont");
- expect(processSpy.mock.calls[1]?.[1]).toBe("follow-up question");
-
- // Queue is drained and the tab is idle again.
- const inner = manager as unknown as {
- tabAgents: Map<string, { messageQueue: unknown[] }>;
- };
- expect(inner.tabAgents.get("tab-cont")?.messageQueue).toHaveLength(0);
- expect(manager.getTabStatus("tab-cont")).toBe("idle");
- });
-
- it('emits message-consumed with reason "continuation" when draining between turns', async () => {
- const manager = new AgentManager();
- const events: AgentEvent[] = [];
- manager.onEvent((e) => events.push(e));
- setRunImpl(runThatEnqueues(manager, "tab-evt", "next"));
-
- await manager.processMessage("tab-evt", "first");
- await new Promise<void>((r) => setTimeout(r, 50));
-
- const consumed = events.find((e) => e.type === "message-consumed") as
- | (AgentEvent & { reason?: string })
- | undefined;
- expect(consumed).toBeDefined();
- expect(consumed?.reason).toBe("continuation");
- });
-
- it("does NOT continue when the queue is empty after a clean turn", async () => {
- const manager = new AgentManager();
- const processSpy = vi.spyOn(manager, "processMessage");
-
- await manager.processMessage("tab-noqueue", "only message");
- await new Promise<void>((r) => setTimeout(r, 30));
-
- expect(processSpy).toHaveBeenCalledTimes(1); // no continuation
- });
-
- it("does NOT continue a turn the user stopped (queue is preserved)", async () => {
- const manager = new AgentManager();
- const processSpy = vi.spyOn(manager, "processMessage");
- // Run that enqueues then aborts itself via stopTab to mimic a user stop.
- setRunImpl(async function* () {
- yield { type: "status", status: "running" } as const;
- manager.queueMessage("tab-stop", "should wait");
- manager.stopTab("tab-stop");
- yield {
- type: "done",
- message: { role: "assistant", chunks: [] },
- } as const;
- });
-
- await manager.processMessage("tab-stop", "go");
- await new Promise<void>((r) => setTimeout(r, 30));
-
- // Only the original turn ran; the queued message is preserved, unanswered.
- expect(processSpy).toHaveBeenCalledTimes(1);
- const inner = manager as unknown as {
- tabAgents: Map<string, { messageQueue: unknown[] }>;
- };
- expect(inner.tabAgents.get("tab-stop")?.messageQueue).toHaveLength(1);
- });
-
- it("bounds runaway agent<->agent continuation via the auto-wake budget", async () => {
- const manager = new AgentManager();
- // A run that ALWAYS enqueues another message → would loop forever
- // without the budget cap.
- setRunImpl(async function* () {
- yield { type: "status", status: "running" } as const;
- manager.queueMessage("tab-loop", "again and again");
- yield {
- type: "done",
- message: { role: "assistant", chunks: [{ type: "text", text: "r" }] },
- } as const;
- yield { type: "status", status: "idle" } as const;
- });
- const processSpy = vi.spyOn(manager, "processMessage");
-
- await manager.processMessage("tab-loop", "kick off");
- await new Promise<void>((r) => setTimeout(r, 120));
-
- // 1 original + at most MAX_AGENT_AUTO_WAKES (6) continuations = 7.
- // Crucially BOUNDED, not infinite.
- expect(processSpy.mock.calls.length).toBeLessThanOrEqual(7);
- expect(processSpy.mock.calls.length).toBeGreaterThan(1);
- // Budget spent; the last queued message is held, not answered.
- const inner = manager as unknown as {
- tabAgents: Map<string, { autoWakeBudget: number; messageQueue: unknown[] }>;
- };
- expect(inner.tabAgents.get("tab-loop")?.autoWakeBudget).toBe(0);
- expect(inner.tabAgents.get("tab-loop")?.messageQueue.length).toBeGreaterThan(0);
- });
- });
-
- describe("getLastTabResponse", () => {
- it("returns the most recent assistant turn's text and current status", () => {
- const manager = new AgentManager();
- setFakeMessages("tab-hist", [
- makeRow("tab-hist", 1, "user", [{ type: "text", text: "hi" }]),
- makeRow("tab-hist", 2, "assistant", [{ type: "text", text: "first answer" }]),
- makeRow("tab-hist", 3, "user", [{ type: "text", text: "again" }]),
- makeRow("tab-hist", 4, "assistant", [
- { type: "text", text: "second " },
- { type: "text", text: "answer" },
- ]),
- ]);
-
- const res = manager.getLastTabResponse("tab-hist");
- expect(res.text).toBe("second answer");
- expect(res.status).toBe("idle");
- });
-
- it("returns null text when the tab has no assistant turn yet", () => {
- const manager = new AgentManager();
- setFakeMessages("tab-empty", [
- makeRow("tab-empty", 1, "user", [{ type: "text", text: "hi" }]),
- ]);
- const res = manager.getLastTabResponse("tab-empty");
- expect(res.text).toBeNull();
- });
-
- it("skips assistant turns that contain no text chunks", () => {
- const manager = new AgentManager();
- setFakeMessages("tab-toolonly", [
- makeRow("tab-toolonly", 1, "assistant", [{ type: "text", text: "real answer" }]),
- // A later assistant turn with only non-text chunks should be skipped.
- makeRow("tab-toolonly", 2, "assistant", [{ type: "thinking", text: "hmm" }]),
- ]);
- const res = manager.getLastTabResponse("tab-toolonly");
- expect(res.text).toBe("real answer");
- });
- });
-
- describe("send_to_tab / read_tab permission split", () => {
- // Drives the real parent-path tool construction in getOrCreateAgentForTab
- // by toggling the new split permissions and inspecting which tools the
- // constructed Agent received.
- async function toolsForPerms(tabId: string, perms: Record<string, string>): Promise<string[]> {
- for (const [k, v] of Object.entries(perms)) setFakeSetting(k, v);
- const manager = new AgentManager();
- await manager.processMessage(tabId, "go");
- return constructedAgents.at(-1)?.toolNames ?? [];
- }
-
- it("grants only send_to_tab when only perm_send_to_tab is allowed", async () => {
- const tools = await toolsForPerms("tab-send-only", { perm_send_to_tab: "allow" });
- expect(tools).toContain("send_to_tab");
- expect(tools).not.toContain("read_tab");
- });
-
- it("grants only read_tab when only perm_read_tab is allowed", async () => {
- const tools = await toolsForPerms("tab-read-only", { perm_read_tab: "allow" });
- expect(tools).toContain("read_tab");
- expect(tools).not.toContain("send_to_tab");
- });
-
- it("grants both when both permissions are allowed", async () => {
- const tools = await toolsForPerms("tab-both", {
- perm_send_to_tab: "allow",
- perm_read_tab: "allow",
- });
- expect(tools).toContain("send_to_tab");
- expect(tools).toContain("read_tab");
- });
-
- it("grants neither when both permissions are off", async () => {
- const tools = await toolsForPerms("tab-neither", {});
- expect(tools).not.toContain("send_to_tab");
- expect(tools).not.toContain("read_tab");
- });
- });
-
- describe("search_code permission gating", () => {
- // Reuses the parent-path tool construction to confirm the perm flag wires
- // the search_code tool on/off correctly.
- async function toolsForPerms(tabId: string, perms: Record<string, string>): Promise<string[]> {
- for (const [k, v] of Object.entries(perms)) setFakeSetting(k, v);
- const manager = new AgentManager();
- await manager.processMessage(tabId, "go");
- return constructedAgents.at(-1)?.toolNames ?? [];
- }
-
- it("grants search_code when perm_search_code is allowed", async () => {
- const tools = await toolsForPerms("tab-cs-on", { perm_search_code: "allow" });
- expect(tools).toContain("search_code");
- });
-
- it("omits search_code when perm_search_code is not allowed", async () => {
- const tools = await toolsForPerms("tab-cs-off", {});
- expect(tools).not.toContain("search_code");
- });
- });
-
- describe("summon / user_agent permission split", () => {
- // Drives the real parent-path tool construction in
- // getOrCreateAgentForTab by toggling perm_summon and perm_user_agent
- // independently, then inspecting which tools the constructed Agent
- // received. The summon tool must be registered when EITHER permission
- // is granted; `retrieve` rides with the subagent permission only
- // (user agents are fire-and-forget).
- async function toolsForPerms(tabId: string, perms: Record<string, string>): Promise<string[]> {
- for (const [k, v] of Object.entries(perms)) setFakeSetting(k, v);
- const manager = new AgentManager();
- await manager.processMessage(tabId, "go");
- return constructedAgents.at(-1)?.toolNames ?? [];
- }
-
- it("grants summon + retrieve when only perm_summon is allowed", async () => {
- const tools = await toolsForPerms("tab-summon-only", { perm_summon: "allow" });
- expect(tools).toContain("summon");
- expect(tools).toContain("retrieve");
- });
-
- it("grants summon WITHOUT retrieve when only perm_user_agent is allowed", async () => {
- // Regression: granting only the user-agent permission used to leave
- // the agent unable to summon user agents because the whole summon
- // tool was gated behind perm_summon.
- const tools = await toolsForPerms("tab-user-agent-only", { perm_user_agent: "allow" });
- expect(tools).toContain("summon");
- expect(tools).not.toContain("retrieve");
- });
-
- it("grants summon + retrieve when both permissions are allowed", async () => {
- const tools = await toolsForPerms("tab-summon-both", {
- perm_summon: "allow",
- perm_user_agent: "allow",
- });
- expect(tools).toContain("summon");
- expect(tools).toContain("retrieve");
- });
-
- it("grants neither summon nor retrieve when both permissions are off", async () => {
- const tools = await toolsForPerms("tab-summon-neither", {});
- expect(tools).not.toContain("summon");
- expect(tools).not.toContain("retrieve");
- });
- });
-
- describe("key_usage permission gate", () => {
- // The key_usage tool is conditionally useful, so it must be COMPLETELY
- // absent from the toolset (and thus the model's context) unless
- // perm_key_usage is explicitly allowed.
- async function toolsForPerms(tabId: string, perms: Record<string, string>): Promise<string[]> {
- for (const [k, v] of Object.entries(perms)) setFakeSetting(k, v);
- const manager = new AgentManager();
- await manager.processMessage(tabId, "go");
- return constructedAgents.at(-1)?.toolNames ?? [];
- }
-
- it("registers key_usage when perm_key_usage is allowed", async () => {
- const tools = await toolsForPerms("tab-key-usage-on", { perm_key_usage: "allow" });
- expect(tools).toContain("key_usage");
- });
-
- it("omits key_usage when perm_key_usage is not allowed", async () => {
- const tools = await toolsForPerms("tab-key-usage-off", {});
- expect(tools).not.toContain("key_usage");
- });
- });
-
- // Regression: granted tab-messaging tools must also be ADVERTISED in the
- // agent's system prompt. The tools were registered in the API tool payload
- // but `buildSystemPrompt` filtered its "You have access to the following
- // tools" list through TOOL_DESCRIPTIONS, which lacked send_to_tab/read_tab
- // — so the model was told it didn't have them and refused to use them. This
- // locks the prompt's capability list to the granted toolset.
- describe("send_to_tab / read_tab system-prompt advertisement", () => {
- async function promptForPerms(tabId: string, perms: Record<string, string>): Promise<string> {
- for (const [k, v] of Object.entries(perms)) setFakeSetting(k, v);
- const manager = new AgentManager();
- await manager.processMessage(tabId, "go");
- return constructedAgents.at(-1)?.systemPrompt ?? "";
- }
-
- it("lists send_to_tab in the system prompt when granted", async () => {
- const prompt = await promptForPerms("tab-prompt-send", { perm_send_to_tab: "allow" });
- expect(prompt).toContain("- send_to_tab:");
- expect(prompt).not.toContain("- read_tab:");
- });
-
- it("lists read_tab in the system prompt when granted", async () => {
- const prompt = await promptForPerms("tab-prompt-read", { perm_read_tab: "allow" });
- expect(prompt).toContain("- read_tab:");
- expect(prompt).not.toContain("- send_to_tab:");
- });
-
- it("lists both tab-messaging tools when both are granted", async () => {
- const prompt = await promptForPerms("tab-prompt-both", {
- perm_send_to_tab: "allow",
- perm_read_tab: "allow",
- });
- expect(prompt).toContain("- send_to_tab:");
- expect(prompt).toContain("- read_tab:");
- });
-
- it("omits both from the system prompt when neither is granted", async () => {
- const prompt = await promptForPerms("tab-prompt-neither", {});
- expect(prompt).not.toContain("- send_to_tab:");
- expect(prompt).not.toContain("- read_tab:");
- });
-
- it("advertises exactly the granted tab tools (prompt list matches schema)", async () => {
- for (const [k, v] of Object.entries({
- perm_send_to_tab: "allow",
- perm_read_tab: "allow",
- })) {
- setFakeSetting(k, v);
- }
- const manager = new AgentManager();
- await manager.processMessage("tab-prompt-match", "go");
- const inst = constructedAgents.at(-1);
- // Every granted tab-messaging tool surfaced in the schema must also be
- // advertised in the prompt, so the model never believes it lacks one.
- for (const name of ["send_to_tab", "read_tab"]) {
- expect(inst?.toolNames).toContain(name);
- expect(inst?.systemPrompt).toContain(`- ${name}:`);
- }
- });
- });
-
- // ─── Usage side-channel persistence ──────────────────────────────
- //
- // `usage` AgentEvents (one per LLM round-trip) are persisted as invisible
- // `type:"usage"` chunk rows so per-tab token/cache telemetry survives a
- // reload. They ride the SAME atomic appendChunks call as the turn's content
- // rows (one fsync, contiguous seqs). A superseded fallback attempt's usage is
- // discarded with its `chunks` (per-attempt accumulator).
- describe("usage persistence", () => {
- it("writes one usage row per usage event emitted during a turn", async () => {
- const manager = new AgentManager();
- setRunImpl(async function* () {
- yield { type: "status", status: "running" } as const;
- yield {
- type: "usage",
- usage: { inputTokens: 1000, outputTokens: 40, cacheReadTokens: 0, cacheWriteTokens: 900 },
- } as const;
- yield { type: "text-delta", delta: "step two" } as const;
- yield {
- type: "usage",
- usage: {
- inputTokens: 1200,
- outputTokens: 60,
- cacheReadTokens: 1000,
- cacheWriteTokens: 100,
- },
- } as const;
- yield {
- type: "done",
- message: { role: "assistant", chunks: [{ type: "text", text: "step two" }] },
- } as const;
- yield { type: "status", status: "idle" } as const;
- });
-
- await manager.processMessage("tab-usage-rows", "go");
-
- const usageDrafts = appendChunksCalls
- .flatMap((c) => c.drafts)
- .filter((d) => d.type === "usage");
- expect(usageDrafts).toHaveLength(2);
- // One row per event, role=assistant, step cosmetic (0).
- expect(usageDrafts.every((d) => d.role === "assistant" && d.step === 0)).toBe(true);
- expect(usageDrafts[0]?.data).toEqual({
- inputTokens: 1000,
- outputTokens: 40,
- cacheReadTokens: 0,
- cacheWriteTokens: 900,
- });
- expect(usageDrafts[1]?.data).toEqual({
- inputTokens: 1200,
- outputTokens: 60,
- cacheReadTokens: 1000,
- cacheWriteTokens: 100,
- });
- });
-
- it("attaches the DB usage aggregate to the turn-sealed event for live reconciliation", async () => {
- const manager = new AgentManager();
- const aggregate = {
- inputTokens: 222,
- outputTokens: 22,
- cacheReadTokens: 100,
- cacheWriteTokens: 5,
- requests: 1,
- last: { inputTokens: 222, outputTokens: 22, cacheReadTokens: 100, cacheWriteTokens: 5 },
- };
- fakeUsageStatsByTab.set("tab-sealed-usage", aggregate);
-
- const events: AgentEvent[] = [];
- manager.onEvent((event) => {
- events.push(event);
- });
-
- await manager.processMessage("tab-sealed-usage", "go");
-
- const sealed = events.find((e) => e.type === "turn-sealed") as
- | Extract<AgentEvent, { type: "turn-sealed" }>
- | undefined;
- expect(sealed).toBeDefined();
- // The aggregate read AFTER the write is carried on the event so the
- // frontend can REPLACE its live cacheStats with the DB truth.
- expect(sealed?.usageStats).toEqual(aggregate);
- });
-
- it("emits usage rows in the SAME appendChunks call as the turn's content (one atomic write)", async () => {
- const manager = new AgentManager();
- setRunImpl(async function* () {
- yield { type: "status", status: "running" } as const;
- yield { type: "text-delta", delta: "hi" } as const;
- yield {
- type: "usage",
- usage: { inputTokens: 5, outputTokens: 1, cacheReadTokens: 2, cacheWriteTokens: 3 },
- } as const;
- yield {
- type: "done",
- message: { role: "assistant", chunks: [{ type: "text", text: "hi" }] },
- } as const;
- yield { type: "status", status: "idle" } as const;
- });
-
- await manager.processMessage("tab-usage-atomic", "go");
-
- // Exactly one appendChunks call carries the usage draft (the flush). The
- // user-message append and any system-row appends carry no usage rows.
- const callsWithUsage = appendChunksCalls.filter((c) =>
- c.drafts.some((d) => d.type === "usage"),
- );
- expect(callsWithUsage).toHaveLength(1);
- expect(callsWithUsage[0]?.tabId).toBe("tab-usage-atomic");
- });
-
- it("discards a superseded (rate-limited) attempt's usage on fallback", async () => {
- const manager = new AgentManager();
- // Inject a minimal model registry so the rate-limit fallback path is
- // taken (real `processMessage` requires modelRegistry + a resolved
- // keyId + a next fallback entry to retry).
- const markKeyExhausted = vi.fn();
- (
- manager as unknown as {
- modelRegistry: {
- getKeys(): Array<{ definition: Record<string, unknown> }>;
- markKeyExhausted(): void;
- };
- }
- ).modelRegistry = {
- getKeys: () => [
- {
- definition: {
- id: "k1",
- provider: "openai-compatible",
- env: "ENV1",
- base_url: "http://x",
- },
- },
- {
- definition: {
- id: "k2",
- provider: "openai-compatible",
- env: "ENV2",
- base_url: "http://y",
- },
- },
- ],
- markKeyExhausted,
- };
-
- let attempt = 0;
- setRunImpl(async function* () {
- attempt++;
- yield { type: "status", status: "running" } as const;
- if (attempt === 1) {
- // Attempt 1 emits usage then rate-limits — its usage must be dropped.
- yield {
- type: "usage",
- usage: { inputTokens: 999, outputTokens: 9, cacheReadTokens: 0, cacheWriteTokens: 0 },
- } as const;
- yield { type: "error", error: "rate limit exceeded (status=429)" } as const;
- return;
- }
- // Attempt 2 succeeds — only its usage should persist.
- yield {
- type: "usage",
- usage: { inputTokens: 222, outputTokens: 22, cacheReadTokens: 100, cacheWriteTokens: 5 },
- } as const;
- yield {
- type: "done",
- message: { role: "assistant", chunks: [{ type: "text", text: "recovered" }] },
- } as const;
- yield { type: "status", status: "idle" } as const;
- });
-
- const agentModels = [
- { key_id: "k1", model_id: "m1" },
- { key_id: "k2", model_id: "m2" },
- ];
- await manager.processMessage(
- "tab-usage-fallback",
- "go",
- undefined,
- undefined,
- undefined,
- undefined,
- agentModels,
- );
-
- expect(attempt).toBe(2); // confirm the fallback retry actually happened
- expect(markKeyExhausted).toHaveBeenCalled();
-
- const usageDrafts = appendChunksCalls
- .flatMap((c) => c.drafts)
- .filter((d) => d.type === "usage");
- // Only attempt 2's usage survives.
- expect(usageDrafts).toHaveLength(1);
- expect(usageDrafts[0]?.data).toEqual({
- inputTokens: 222,
- outputTokens: 22,
- cacheReadTokens: 100,
- cacheWriteTokens: 5,
- });
- });
- });
-
- describe("warmCacheForTab (prompt-cache warming)", () => {
- it("returns the warm request usage and forwards the FULL genuine history", async () => {
- const manager = new AgentManager();
- setFakeMessages("tab-warm", [
- makeRow("tab-warm", 1, "user", [{ type: "text", text: "hello" }]),
- makeRow("tab-warm", 2, "assistant", [{ type: "text", text: "hi" }]),
- ]);
-
- const result = await manager.warmCacheForTab("tab-warm");
- expect(result.ok).toBe(true);
- if (result.ok) {
- expect(result.usage).toEqual({
- inputTokens: 1200,
- outputTokens: 1,
- cacheReadTokens: 1100,
- cacheWriteTokens: 0,
- });
- }
- // The genuine history is forwarded UNTRIMMED (both turns), so the
- // replayed prefix matches the next real turn exactly.
- expect(capturedWarmHistories).toHaveLength(1);
- expect(capturedWarmHistories[0]).toHaveLength(2);
- });
-
- it("does NOT persist anything (no appendChunks for the warm request)", async () => {
- const manager = new AgentManager();
- setFakeMessages("tab-warm-2", [
- makeRow("tab-warm-2", 1, "user", [{ type: "text", text: "hello" }]),
- ]);
- await manager.warmCacheForTab("tab-warm-2");
- // Warming must never write chunk rows (history / usage / anything).
- expect(appendChunksCalls).toHaveLength(0);
- });
-
- it("refuses to warm while the tab is generating", async () => {
- const manager = new AgentManager();
- // Start a turn (status flips to running) but don't await it.
- const running = manager.processMessage("tab-warm-busy", "go");
- // Let the mock run() yield its first running status.
- await new Promise<void>((r) => setTimeout(r, 1));
- const result = await manager.warmCacheForTab("tab-warm-busy");
- expect(result.ok).toBe(false);
- if (!result.ok) expect(result.error).toBe("tab is generating");
- await running;
- });
- });
-});
-
-describe("AgentManager.compactTab", () => {
- beforeEach(() => {
- resetFakeMessages();
- resetConstructedAgents();
- resetCapturedRunOptions();
- resetFakeTabs();
- resetFakeSettings();
- setRunImpl(null);
- appendEventToChunksSpy.mockClear();
- resetAppendChunksCalls();
- resetFakeUsageStats();
- resetCompactionScaffolding();
- });
-
- /** Seed a usable compaction model (config key + registry key + env key). */
- function seedCompactorModel(keyId = "k1", modelId = "m1"): void {
- fakeConfigKeys.push({ id: keyId, provider: "opencode-go", base_url: "https://x", env: "K1" });
- fakeRegistryKeys.push({ id: keyId, provider: "opencode-go", base_url: "https://x", env: "K1" });
- fakeApiKeys.set(keyId, "secret");
- setFakeSetting("compaction_model_key_id", keyId);
- setFakeSetting("compaction_model_id", modelId);
- }
-
- it("errors when the conversation is too short to compact (empty prompt)", async () => {
- const manager = new AgentManager();
- const events: AgentEvent[] = [];
- manager.onEvent((e) => events.push(e));
-
- setFakeTab({ id: "src", title: "Chat" });
- fakeChunksByTab.set("src", [{ tabId: "src" }]);
- // No prompt → nothing to compact.
- fakeCompactionByTab.set("src", { tail: [], prompt: undefined });
-
- await manager.compactTab("temp", "src");
-
- const err = events.find((e) => e.type === "compaction-error");
- expect(err).toMatchObject({ type: "compaction-error", tempTabId: "temp", sourceTabId: "src" });
- // No backup tab created, no rekey.
- expect(createTabCalls).toHaveLength(0);
- expect(rekeyCalls).toHaveLength(0);
- });
-
- it("errors when no compaction model can be resolved", async () => {
- const manager = new AgentManager();
- const events: AgentEvent[] = [];
- manager.onEvent((e) => events.push(e));
-
- setFakeTab({ id: "src", title: "Chat", keyId: null, modelId: null });
- fakeChunksByTab.set("src", [{ tabId: "src" }]);
- // Compactable, but no configured model and the tab has no key/model.
- // (no seedCompactorModel call)
-
- await manager.compactTab("temp", "src");
-
- expect(events.some((e) => e.type === "compaction-error")).toBe(true);
- expect(rekeyCalls).toHaveLength(0);
- });
-
- it("refuses to compact while the source tab is running", async () => {
- const manager = new AgentManager();
- const events: AgentEvent[] = [];
- manager.onEvent((e) => events.push(e));
-
- setFakeTab({ id: "src", title: "Chat" });
- fakeChunksByTab.set("src", [{ tabId: "src" }]);
- // Start a (never-finishing) turn so the tab is "running".
- setRunImpl(async function* () {
- yield { type: "status", status: "running" } as const;
- await new Promise<void>((r) => setTimeout(r, 50));
- yield { type: "status", status: "idle" } as const;
- });
- void manager.processMessage("src", "hi");
- // Give processMessage a tick to flip status to running.
- await new Promise<void>((r) => setTimeout(r, 5));
-
- await manager.compactTab("temp", "src");
- const err = events.find((e) => e.type === "compaction-error");
- expect(err).toBeDefined();
- expect(rekeyCalls).toHaveLength(0);
- });
-
- it("happy path: summarizes, relocates history to a backup, re-seeds the source", async () => {
- seedCompactorModel();
- const manager = new AgentManager();
- const events: AgentEvent[] = [];
- manager.onEvent((e) => events.push(e));
-
- setFakeTab({ id: "src", title: "My Chat", keyId: "k1", modelId: "m1" });
- fakeChunksByTab.set("src", [{ tabId: "src" }, { tabId: "src" }]);
- fakeCompactionByTab.set("src", {
- prompt: "SUMMARY PROMPT",
- tail: [
- { turnId: "tail-u", role: "user", chunks: [{ type: "text", text: "recent q" }] },
- { turnId: "tail-a", role: "assistant", chunks: [{ type: "text", text: "recent a" }] },
- ],
- });
-
- await manager.compactTab("temp", "src");
-
- // started + complete emitted; no error.
- expect(events.some((e) => e.type === "compaction-started")).toBe(true);
- const complete = events.find((e) => e.type === "compaction-complete") as
- | (AgentEvent & { backupTabId: string; backupTitle: string })
- | undefined;
- expect(complete).toBeDefined();
- expect(events.some((e) => e.type === "compaction-error")).toBe(false);
-
- // A backup tab was created and history was relocated onto it.
- expect(createTabCalls).toHaveLength(1);
- expect(rekeyCalls).toHaveLength(1);
- expect(rekeyCalls[0]).toMatchObject({ from: "src", to: createTabCalls[0]?.id });
- expect(complete?.backupTabId).toBe(createTabCalls[0]?.id);
- expect(complete?.backupTitle).toContain("pre-compaction");
-
- // The source was re-seeded: appendChunks was called for "src" after rekey
- // (summary turn + preserved tail).
- const srcAppends = appendChunksCalls.filter((c) => c.tabId === "src");
- expect(srcAppends.length).toBeGreaterThanOrEqual(1);
- });
-
- it("queues messages sent to the source while compaction is in flight", async () => {
- seedCompactorModel();
- const manager = new AgentManager();
- setFakeTab({ id: "src", title: "Chat", keyId: "k1", modelId: "m1" });
- fakeChunksByTab.set("src", [{ tabId: "src" }]);
- fakeCompactionByTab.set("src", {
- prompt: "SUMMARY PROMPT",
- tail: [{ turnId: "t", role: "user", chunks: [{ type: "text", text: "recent" }] }],
- });
-
- // Make the summary generation slow so we can deliver mid-compaction.
- setRunImpl(async function* () {
- yield { type: "status", status: "running" } as const;
- await new Promise<void>((r) => setTimeout(r, 40));
- yield { type: "text-delta", delta: "summary text" } as const;
- yield { type: "status", status: "idle" } as const;
- });
-
- const compaction = manager.compactTab("temp", "src");
- await new Promise<void>((r) => setTimeout(r, 10));
- // While compacting, a human message should be QUEUED, not started.
- const result = manager.deliverMessage("src", "hello during compaction");
- expect(result.status).toBe("queued");
-
- await compaction;
- });
-});
diff --git a/packages/api/tests/permission-manager.test.ts b/packages/api/tests/permission-manager.test.ts
deleted file mode 100644
index 172adb3..0000000
--- a/packages/api/tests/permission-manager.test.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-
-// Mock @dispatch/core to provide only the PermissionService impl this test
-// touches — the core barrel transitively pulls in bun:sqlite, which vitest
-// running under Node cannot resolve.
-vi.mock("@dispatch/core", async () => {
- const mod = await import("../../core/src/permission/service.js");
- return {
- PermissionService: mod.PermissionService,
- };
-});
-
-const { PermissionManager } = await import("../src/permission-manager.js");
-
-interface PermissionRequest {
- permission: string;
- patterns: string[];
- always: string[];
- description: string;
- metadata: Record<string, unknown>;
-}
-
-function makeRequest(overrides: Partial<PermissionRequest> = {}): PermissionRequest {
- return {
- permission: "bash",
- patterns: ["git *"],
- always: ["git status"],
- description: "Run git status",
- metadata: {},
- ...overrides,
- };
-}
-
-describe("PermissionManager.onPromptAdded", () => {
- it("fires once per newly-added pending prompt", () => {
- const mgr = new PermissionManager();
- const seen: Array<{ id: string; permission: string }> = [];
- mgr.onPromptAdded((p) => {
- seen.push({ id: p.id, permission: p.permission });
- });
-
- void mgr.ask(makeRequest(), []);
- void mgr.ask(makeRequest({ permission: "read", description: "Read X" }), []);
-
- expect(seen).toHaveLength(2);
- expect(seen[0].permission).toBe("bash");
- expect(seen[1].permission).toBe("read");
- // Distinct ids
- expect(seen[0].id).not.toBe(seen[1].id);
- });
-
- it("does not re-fire when the pending list is rebroadcast for an unrelated change", async () => {
- const mgr = new PermissionManager();
- const seen: string[] = [];
- mgr.onPromptAdded((p) => seen.push(p.id));
-
- // Two prompts in; should see two notifications.
- const p1 = mgr.ask(makeRequest(), []);
- void mgr.ask(makeRequest({ permission: "read" }), []);
- expect(seen).toHaveLength(2);
-
- // Resolve the first one — broadcastPending fires again, but the
- // remaining (already-announced) prompt must NOT re-notify.
- const pending = mgr.getPending();
- const firstId = pending[0].id;
- mgr.reply(firstId, "once");
- await p1;
-
- expect(seen).toHaveLength(2);
- });
-
- it("unsubscribe stops further notifications", () => {
- const mgr = new PermissionManager();
- const seen: string[] = [];
- const unsub = mgr.onPromptAdded((p) => seen.push(p.id));
- void mgr.ask(makeRequest(), []);
- unsub();
- void mgr.ask(makeRequest({ permission: "read" }), []);
- expect(seen).toHaveLength(1);
- });
-
- it("listener throws are caught and don't break other listeners", () => {
- const mgr = new PermissionManager();
- const seen: string[] = [];
- mgr.onPromptAdded(() => {
- throw new Error("boom");
- });
- mgr.onPromptAdded((p) => seen.push(p.id));
- // Swallow the warn during this test.
- const origWarn = console.warn;
- console.warn = () => {};
- try {
- void mgr.ask(makeRequest(), []);
- } finally {
- console.warn = origWarn;
- }
- expect(seen).toHaveLength(1);
- });
-});
diff --git a/packages/api/tests/routes.test.ts b/packages/api/tests/routes.test.ts
deleted file mode 100644
index 2d5c7c7..0000000
--- a/packages/api/tests/routes.test.ts
+++ /dev/null
@@ -1,1097 +0,0 @@
-import type { ToolDefinition } from "@dispatch/core";
-import { describe, expect, it, vi } from "vitest";
-
-// Seedable backing stores for the tabs route (GET /tabs enrichment). Declared
-// before vi.mock so the hoisted factory closure can reference them; populated
-// per-test.
-interface FakeOpenTab {
- id: string;
- title: string;
- keyId: string | null;
- modelId: string | null;
- parentTabId: string | null;
- status: string;
- isOpen: boolean;
- position: number;
- createdAt: number;
- updatedAt: number;
-}
-const fakeOpenTabs: FakeOpenTab[] = [];
-const fakeUsageStats = new Map<string, unknown>();
-
-// Mock @dispatch/core's Agent to avoid real LLM calls
-vi.mock("@dispatch/core", () => ({
- Agent: class MockAgent {
- status = "idle";
- messages: unknown[] = [];
- async warmCache(_history: unknown[]) {
- // Simulate a warm replay that read most of the prompt from cache.
- return {
- inputTokens: 1000,
- outputTokens: 1,
- cacheReadTokens: 900,
- cacheWriteTokens: 0,
- };
- }
- async *run(_message: string) {
- yield { type: "status", status: "running" } as const;
- // Simulate some processing time so status stays "running"
- await new Promise<void>((r) => setTimeout(r, 100));
- yield { type: "text-delta", delta: "Hello " } as const;
- yield { type: "text-delta", delta: "world" } as const;
- yield {
- type: "done",
- message: {
- role: "assistant",
- chunks: [{ type: "text", text: "Hello world" }],
- },
- } as const;
- yield { type: "status", status: "idle" } as const;
- }
- },
- PermissionService: class MockPermissionService {
- ask(_request: unknown, _rulesets: unknown[]) {
- return Promise.resolve("once");
- }
- reply(_id: string, _reply: unknown) {}
- getPending() {
- return [];
- }
- },
- createReadFileTool(_wd: string): ToolDefinition {
- return {
- name: "read_file",
- description: "read a file",
- parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
- execute: async () => "mock file content",
- };
- },
- createReadFileSliceTool(_wd: string): ToolDefinition {
- return {
- name: "read_file_slice",
- description: "read a char slice of a single line",
- parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
- execute: async () => "mock slice",
- };
- },
- clearSpillForTab(_tabId: string) {},
- createWriteFileTool(_wd: string): ToolDefinition {
- return {
- name: "write_file",
- description: "write a file",
- parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
- execute: async () => true,
- };
- },
- createListFilesTool(_wd: string): ToolDefinition {
- return {
- name: "list_files",
- description: "list files",
- parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
- execute: async () => ["file1.ts"],
- };
- },
- createLspTool(_getContext: unknown): ToolDefinition {
- return {
- name: "lsp",
- description: "query the language server",
- parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
- execute: async () => "mock lsp",
- };
- },
- LspManager: class MockLspManager {
- hasServerForFile() {
- return false;
- }
- async getClients() {
- return [];
- }
- async touchFile() {}
- getDiagnostics() {
- return {};
- }
- async request() {
- return [];
- }
- async shutdownAll() {}
- },
- resolveServersFromConfig(_lsp: unknown) {
- return [];
- },
- reportDiagnostics(_file: string, _issues: unknown) {
- return "";
- },
- createRunShellTool(_wd: string): ToolDefinition {
- return {
- name: "run_shell",
- description: "run shell command",
- parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
- execute: async () => ({ stdout: "", stderr: "", exitCode: 0 }),
- };
- },
- loadConfig(_dir: string) {
- return { permissions: {} };
- },
- configToRuleset(_config: unknown) {
- return [];
- },
- validateConfig(_config: unknown) {
- return { config: _config, errors: [] };
- },
- createConfigWatcher(_dir: string, _onChange: unknown) {
- return { close() {} };
- },
- watchDirConfig(_dir: string, _onChange: unknown) {
- return { close() {} };
- },
- loadSkills(_dir: string) {
- return { skills: [], mappings: [] };
- },
- createSkillsWatcher(_dir: string, _onChange: unknown) {
- return { close() {} };
- },
- ModelRegistry: class MockModelRegistry {
- getModels() {
- return [];
- }
- getKeys() {
- return [];
- }
- getModelsByTag(_tag: string) {
- return [];
- }
- getAllTags() {
- return [];
- }
- hasAvailableKey(_provider: string) {
- return false;
- }
- allKeysExhausted() {
- return true;
- }
- markKeyExhausted() {}
- markKeyActive() {}
- updateConfig() {}
- },
- ModelResolver: class MockModelResolver {
- resolve(_tag: string) {
- return null;
- }
- waitForKey() {
- return Promise.resolve(null);
- }
- },
- TaskList: class MockTaskList {
- private tasks: Array<{ id: string; content: string; status: string }> = [];
- getTasks() {
- return this.tasks.map((t) => ({ ...t }));
- }
- setTasks(items: Array<{ content: string; status?: string }>) {
- this.tasks = items.map((item, i) => ({
- id: `task-${i + 1}`,
- content: item.content,
- status: item.status ?? "pending",
- }));
- return this.getTasks();
- }
- onChange(_cb: unknown) {
- return () => {};
- }
- },
- createTaskListTool(_taskList: unknown) {
- return {
- name: "todo",
- description: "todo",
- parameters: { _type: "z.ZodObject", shape: {} },
- execute: async () => "mock",
- };
- },
- createSummonTool(_wd: string, _callbacks: unknown) {
- return {
- name: "summon",
- description: "summon",
- parameters: { _type: "z.ZodObject", shape: {} },
- execute: async () => "mock",
- };
- },
- createRetrieveTool(_callbacks: unknown) {
- return {
- name: "retrieve",
- description: "retrieve",
- parameters: { _type: "z.ZodObject", shape: {} },
- execute: async () => "mock",
- };
- },
- createTab() {},
- getTab() {
- return null;
- },
- isReasoningEffort(value: unknown) {
- return (
- typeof value === "string" && ["none", "low", "medium", "high", "xhigh", "max"].includes(value)
- );
- },
- // Lightweight stand-in for the real validator: accept the supported media
- // types, reject everything else. Enough to exercise the /chat attachment
- // validation branch (the real validator is unit-tested in core).
- validateUserContent(content: Array<{ type: string; mediaType?: string }>) {
- const accepted = ["image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf"];
- const errors = content
- .filter((p) => p.type === "attachment" && !accepted.includes(p.mediaType ?? ""))
- .map((p) => ({ code: "unsupported-type", mediaType: p.mediaType }));
- return { ok: errors.length === 0, errors };
- },
- listOpenTabs() {
- return [...fakeOpenTabs];
- },
- resolveTabPrefix() {
- return { status: "none" };
- },
- shortestUniquePrefix(id: string) {
- return (id ?? "").slice(0, 4);
- },
- createSendToTabTool(_callbacks: unknown) {
- return {
- name: "send_to_tab",
- description: "send to tab",
- parameters: { _type: "z.ZodObject", shape: {} },
- execute: async () => "mock",
- };
- },
- createReadTabTool(_callbacks: unknown) {
- return {
- name: "read_tab",
- description: "read tab",
- parameters: { _type: "z.ZodObject", shape: {} },
- execute: async () => "mock",
- };
- },
- getClaudeAccountsFromDB() {
- return [];
- },
- refreshAccountCredentials() {
- return null;
- },
- refreshAccountCredentialsAsync() {
- return Promise.resolve(null);
- },
- resolveApiKey() {
- return null;
- },
- getSetting(_key: string) {
- return null;
- },
- setSetting(_key: string, _value: string) {},
- deleteSetting(_key: string) {},
- appendChunks() {
- return [];
- },
- explodeUserText() {
- return [];
- },
- explodeTurn() {
- return [];
- },
- getMessagesForTab() {
- return [];
- },
- getChunksForTab() {
- return [];
- },
- groupRowsToMessages() {
- return [];
- },
- getTotalChunkCount() {
- return 0;
- },
- getUsageStatsForTab(tabId: string) {
- return fakeUsageStats.get(tabId) ?? null;
- },
- appendEventToChunks(_chunks: unknown[], _event: unknown) {
- // no-op stub
- },
- applySystemEvent(_messages: unknown[], _event: unknown) {
- return { messageId: "mock-system-msg" };
- },
- BackgroundShellStore: class MockBackgroundShellStore {
- has() {
- return false;
- }
- getResult() {
- return Promise.resolve({ status: "error", error: "not found" });
- }
- },
- BackgroundTranscriptStore: class MockBackgroundTranscriptStore {
- has() {
- return false;
- }
- getResult() {
- return Promise.resolve({ status: "error", error: "not found" });
- }
- },
- createWebSearchTool() {
- return {
- name: "web_search",
- description: "web search",
- parameters: { _type: "z.ZodObject", shape: {} },
- execute: async () => "mock",
- };
- },
- createSearchCodeTool(_wd: string) {
- return {
- name: "search_code",
- description: "search code",
- parameters: { _type: "z.ZodObject", shape: {} },
- execute: async () => "mock",
- };
- },
- createYoutubeTranscribeTool() {
- return {
- name: "youtube_transcribe",
- description: "youtube transcribe",
- parameters: { _type: "z.ZodObject", shape: {} },
- execute: async () => "mock",
- };
- },
- // ── models.dev context-limit stub ─────────────────────────────
- resolveContextLimit(provider: string, modelId: string) {
- if (provider === "anthropic" && modelId === "claude-sonnet-4-5") {
- return Promise.resolve(200000);
- }
- return Promise.resolve(null);
- },
- // ── ntfy notifications stubs ──────────────────────────────────
- NotificationDispatcher: class MockNotificationDispatcher {
- attachToAgentManager() {
- return () => {};
- }
- attachToPermissionManager() {
- return () => {};
- }
- notify() {}
- dispose() {}
- },
- loadNtfyConfig() {
- return {
- enabled: false,
- topic: "",
- authToken: "",
- events: {
- "turn-completed": true,
- "turn-error": true,
- "permission-required": true,
- "agent-spawned": false,
- },
- notifySubagents: false,
- };
- },
- saveNtfyConfig() {},
- normalizeNtfyConfig(c: unknown) {
- return c;
- },
- defaultNtfyConfig() {
- return {
- enabled: false,
- topic: "",
- authToken: "",
- events: {
- "turn-completed": true,
- "turn-error": true,
- "permission-required": true,
- "agent-spawned": false,
- },
- notifySubagents: false,
- };
- },
- redactNtfyConfig(c: { authToken?: string }) {
- return { ...c, authToken: "", hasAuthToken: false };
- },
- NTFY_EVENT_TYPES: ["turn-completed", "turn-error", "permission-required", "agent-spawned"],
- async sendNtfy() {
- return { ok: true };
- },
-}));
-
-const { app } = await import("../src/app.js");
-
-describe("GET /health", () => {
- it("returns 200 with ok: true", async () => {
- const res = await app.request("/health");
- expect(res.status).toBe(200);
- const body = await res.json();
- expect(body).toEqual({ ok: true });
- });
-});
-
-describe("GET /status", () => {
- it("returns idle status initially", async () => {
- const res = await app.request("/status");
- expect(res.status).toBe(200);
- const body = await res.json();
- expect(body.status).toBe("idle");
- expect(typeof body.messageCount).toBe("number");
- });
-});
-
-describe("POST /chat", () => {
- it("returns 200 with valid message", async () => {
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ tabId: "tab-1", message: "hello world" }),
- });
- expect(res.status).toBe(200);
- const body = await res.json();
- expect(body).toEqual({ status: "ok" });
- });
-
- it("accepts xhigh as a valid reasoningEffort", async () => {
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- tabId: "tab-xhigh",
- message: "hello",
- reasoningEffort: "xhigh",
- }),
- });
- expect(res.status).toBe(200);
- expect(await res.json()).toEqual({ status: "ok" });
- });
-
- it("tolerates an invalid agentModels effort (sanitized, not rejected)", async () => {
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- tabId: "tab-badeffort",
- message: "hello",
- agentModels: [{ key_id: "k", model_id: "m", effort: "turbo" }],
- }),
- });
- expect(res.status).toBe(200);
- expect(await res.json()).toEqual({ status: "ok" });
- });
-
- it("accepts a valid image attachment and starts a turn", async () => {
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- tabId: "tab-img-ok",
- message: "look: [image]",
- content: [
- { type: "text", text: "look: " },
- { type: "attachment", mediaType: "image/png", data: "QQ==" },
- ],
- }),
- });
- expect(res.status).toBe(200);
- expect(await res.json()).toEqual({ status: "ok" });
- });
-
- it("returns 400 for an unsupported attachment media type", async () => {
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- tabId: "tab-img-bad",
- message: "look: [image]",
- content: [{ type: "attachment", mediaType: "image/svg+xml", data: "QQ==" }],
- }),
- });
- expect(res.status).toBe(400);
- const body = await res.json();
- expect(body.error).toBe("invalid attachments");
- });
-
- it("returns 409 when attaching while the agent is generating", async () => {
- // Kick off a turn so the tab is running.
- await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ tabId: "tab-img-busy", message: "first" }),
- });
- await new Promise<void>((r) => setTimeout(r, 20));
-
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- tabId: "tab-img-busy",
- message: "second [image]",
- content: [{ type: "attachment", mediaType: "image/png", data: "QQ==" }],
- }),
- });
- expect(res.status).toBe(409);
- });
-
- it("returns 400 with empty message", async () => {
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ tabId: "tab-1", message: "" }),
- });
- expect(res.status).toBe(400);
- });
-
- it("returns 400 with whitespace-only message", async () => {
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ tabId: "tab-1", message: " " }),
- });
- expect(res.status).toBe(400);
- });
-
- it("returns 400 with missing message field", async () => {
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ tabId: "tab-1" }),
- });
- expect(res.status).toBe(400);
- });
-
- it("returns 400 with missing tabId", async () => {
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ message: "hello" }),
- });
- expect(res.status).toBe(400);
- });
-
- it("queues message when agent is already running", async () => {
- // Start a message (non-blocking)
- await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ tabId: "tab-2", message: "first message" }),
- });
-
- // Small delay to let the async generator start and emit "running" status
- await new Promise<void>((r) => setTimeout(r, 20));
-
- // Send a second — agent should queue it
- const res = await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ tabId: "tab-2", message: "second message" }),
- });
- expect(res.status).toBe(200);
- const body = await res.json();
- expect(body.status).toBe("queued");
- expect(typeof body.messageId).toBe("string");
- });
-});
-
-describe("GET /tabs", () => {
- it("enriches each open tab with its persisted usageStats aggregate", async () => {
- fakeOpenTabs.length = 0;
- fakeUsageStats.clear();
- fakeOpenTabs.push({
- id: "tab-u",
- title: "Has usage",
- keyId: null,
- modelId: null,
- parentTabId: null,
- status: "idle",
- isOpen: true,
- position: 0,
- createdAt: 0,
- updatedAt: 0,
- });
- fakeOpenTabs.push({
- id: "tab-none",
- title: "No usage",
- keyId: null,
- modelId: null,
- parentTabId: null,
- status: "idle",
- isOpen: true,
- position: 1,
- createdAt: 0,
- updatedAt: 0,
- });
- fakeUsageStats.set("tab-u", {
- inputTokens: 2200,
- outputTokens: 100,
- cacheReadTokens: 1000,
- cacheWriteTokens: 1000,
- requests: 2,
- last: { inputTokens: 1200, outputTokens: 60, cacheReadTokens: 1000, cacheWriteTokens: 100 },
- });
-
- const res = await app.request("/tabs");
- expect(res.status).toBe(200);
- const body = await res.json();
- expect(Array.isArray(body.tabs)).toBe(true);
- const tabU = body.tabs.find((t: { id: string }) => t.id === "tab-u");
- const tabNone = body.tabs.find((t: { id: string }) => t.id === "tab-none");
- expect(tabU.usageStats).toEqual({
- inputTokens: 2200,
- outputTokens: 100,
- cacheReadTokens: 1000,
- cacheWriteTokens: 1000,
- requests: 2,
- last: { inputTokens: 1200, outputTokens: 60, cacheReadTokens: 1000, cacheWriteTokens: 100 },
- });
- // A tab with no usage rows surfaces null (not undefined/missing).
- expect(tabNone.usageStats).toBeNull();
-
- fakeOpenTabs.length = 0;
- fakeUsageStats.clear();
- });
-});
-
-describe("GET /tabs/:id/chunks", () => {
- it("returns the raw chunk window shape { chunks, total, oldestSeq }", async () => {
- const res = await app.request("/tabs/tab-x/chunks?limit=50");
- expect(res.status).toBe(200);
- const body = await res.json();
- // Mocked getChunksForTab returns [] → empty window, null cursor.
- expect(Array.isArray(body.chunks)).toBe(true);
- expect(body.chunks).toEqual([]);
- expect(body.total).toBe(0);
- expect(body.oldestSeq).toBeNull();
- });
-});
-
-describe("POST /tabs/:id/compact", () => {
- it("returns 400 when sourceTabId is missing", async () => {
- const res = await app.request("/tabs/temp-1/compact", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({}),
- });
- expect(res.status).toBe(400);
- });
-
- it("returns 202 and kicks off compaction when sourceTabId is provided", async () => {
- const res = await app.request("/tabs/temp-1/compact", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ sourceTabId: "src-1" }),
- });
- expect(res.status).toBe(202);
- const body = await res.json();
- expect(body).toEqual({ success: true });
- });
-});
-
-describe("GET/PUT /tabs/settings/compaction-model", () => {
- it("GET returns the persisted compaction-model setting shape", async () => {
- const res = await app.request("/tabs/settings/compaction-model");
- expect(res.status).toBe(200);
- const body = await res.json();
- // Mocked getSetting → null, so both fields are null.
- expect(body).toEqual({ keyId: null, modelId: null });
- });
-
- it("PUT accepts a key/model pair", async () => {
- const res = await app.request("/tabs/settings/compaction-model", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ keyId: "k1", modelId: "m1" }),
- });
- expect(res.status).toBe(200);
- const body = await res.json();
- expect(body).toEqual({ success: true });
- });
-});
-
-describe("POST /chat/stop", () => {
- it("returns 200 with success: true for valid tabId", async () => {
- const res = await app.request("/chat/stop", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ tabId: "tab-stop-1" }),
- });
- expect(res.status).toBe(200);
- const body = await res.json();
- expect(body).toEqual({ success: true });
- });
-
- it("returns 400 when tabId is missing", async () => {
- const res = await app.request("/chat/stop", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({}),
- });
- expect(res.status).toBe(400);
- });
-});
-
-describe("POST /chat/warm", () => {
- it("returns ONLY the warming request usage (never persisted/emitted)", async () => {
- const res = await app.request("/chat/warm", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ tabId: "tab-warm-1" }),
- });
- expect(res.status).toBe(200);
- const body = (await res.json()) as { usage?: Record<string, number> };
- expect(body.usage).toEqual({
- inputTokens: 1000,
- outputTokens: 1,
- cacheReadTokens: 900,
- cacheWriteTokens: 0,
- });
- });
-
- it("returns 400 when tabId is missing", async () => {
- const res = await app.request("/chat/warm", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({}),
- });
- expect(res.status).toBe(400);
- });
-
- it("returns 409 while the tab is generating", async () => {
- // Kick off a real (mock) turn so the tab is "running", then immediately
- // attempt to warm it — warming must refuse mid-turn.
- await app.request("/chat", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ tabId: "tab-warm-busy", message: "hi" }),
- });
- const res = await app.request("/chat/warm", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ tabId: "tab-warm-busy" }),
- });
- expect(res.status).toBe(409);
- });
-});
-
-describe("Wake schedule routes", () => {
- async function getSchedule() {
- const res = await app.request("/models/wake-schedule");
- expect(res.status).toBe(200);
- return (await res.json()) as {
- schedule: Record<string, Record<string, number>>;
- resetOffsetHours: number;
- probeSlotMinutes: number[];
- lastWake: unknown;
- pendingRetry: unknown;
- };
- }
-
- /**
- * Auto-derives `action` from `timestamps` presence:
- * - body has `timestamps` → action = "on"
- * - body has no `timestamps` → action = "off"
- * Tests can override by passing `action` explicitly. This keeps the
- * intent-vs-state contract enforced (every request carries an explicit
- * action) while keeping the existing test bodies short.
- */
- async function toggle(body: Record<string, unknown>) {
- const withAction: Record<string, unknown> =
- "action" in body ? body : { ...body, action: body.timestamps !== undefined ? "on" : "off" };
- return app.request("/models/wake-schedule/toggle", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(withAction),
- });
- }
-
- /** Build a `timestamps` payload with all 4 probe slots set to absolute ms. */
- function buildTimestamps(base: number): Record<string, number> {
- return { "0": base, "15": base + 60_000, "30": base + 120_000, "45": base + 180_000 };
- }
-
- it("GET returns the full snapshot shape with probeSlotMinutes, resetOffsetHours, lastWake, pendingRetry", async () => {
- const snap = await getSchedule();
- expect(snap.schedule).toBeDefined();
- expect(Number.isInteger(snap.resetOffsetHours)).toBe(true);
- expect(snap.resetOffsetHours).toBeGreaterThan(0);
- expect(snap.probeSlotMinutes).toEqual([0, 15, 30, 45]);
- expect(snap.lastWake).toBeNull();
- expect(snap.pendingRetry).toBeNull();
- });
-
- it("POST toggle adds an hour as 4 probe slots and removes them all together", async () => {
- const base = Date.now() + 60 * 60 * 1000; // 1 h ahead
- const timestamps = buildTimestamps(base);
-
- const addRes = await toggle({ hour: 9, timestamps });
- expect(addRes.status).toBe(200);
- const addBody = (await addRes.json()) as {
- schedule: Record<string, Record<string, number>>;
- };
- expect(addBody.schedule["9"]).toEqual({
- "0": base,
- "15": base + 60_000,
- "30": base + 120_000,
- "45": base + 180_000,
- });
-
- const removeRes = await toggle({ hour: 9 });
- expect(removeRes.status).toBe(200);
- const removeBody = (await removeRes.json()) as {
- schedule: Record<string, Record<string, number>>;
- };
- expect(removeBody.schedule["9"]).toBeUndefined();
- });
-
- it("POST toggle rejects out-of-range hour", async () => {
- const res = await toggle({
- hour: 24,
- timestamps: buildTimestamps(Date.now() + 60_000),
- });
- expect(res.status).toBe(400);
- });
-
- it("POST toggle rejects negative hour", async () => {
- const res = await toggle({
- hour: -1,
- timestamps: buildTimestamps(Date.now() + 60_000),
- });
- expect(res.status).toBe(400);
- });
-
- it("POST toggle rejects non-integer hour", async () => {
- const res = await toggle({
- hour: 4.5,
- timestamps: buildTimestamps(Date.now() + 60_000),
- });
- expect(res.status).toBe(400);
- });
-
- it("POST toggle ACCEPTS a slightly-past timestamp (clock skew / latency)", async () => {
- // Regression guard for Gemini-review finding #1: the old code rejected
- // any slot timestamp <= server now, which broke legitimate toggles when
- // network latency made an imminent slot land "in the past". The HTTP
- // layer must accept it; the scheduler then either fires it immediately
- // (if within MISSED_WAKE_GRACE_MS) or rolls it forward by 24h × N. By
- // the time the response returns, the scheduler tick has already run
- // synchronously up to its first await — so the snapshot reflects the
- // post-advance ts (strictly > now), not the original past ts.
- await toggle({ hour: 22 }); // ensure clean
- const now = Date.now();
- const timestamps: Record<string, number> = {
- "0": now - 5_000, // 5s in the past — well within any plausible skew
- "15": now + 60_000,
- "30": now + 120_000,
- "45": now + 180_000,
- };
- const res = await toggle({ hour: 22, timestamps });
- expect(res.status).toBe(200);
- const body = (await res.json()) as {
- schedule: Record<string, Record<string, number>>;
- };
- const slot0 = body.schedule["22"]?.["0"];
- expect(typeof slot0).toBe("number");
- expect((slot0 ?? 0) > now).toBe(true); // slot :00 was advanced by the tick
- expect(body.schedule["22"]?.["15"]).toBe(now + 60_000); // future slot kept
- await toggle({ hour: 22 }); // cleanup
- });
-
- it("POST toggle rejects NaN / Infinity / non-number slot values", async () => {
- // Use a dedicated hour and ALWAYS clean it up, even on assertion failure,
- // so we don't leak state into the next iteration (which would otherwise
- // interpret the next toggle as a DELETE and return 200).
- const hour = 23;
- await toggle({ hour }); // ensure clean
- for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, "x", null]) {
- const now = Date.now();
- const timestamps: Record<string, unknown> = {
- "0": now + 60_000,
- "15": bad,
- "30": now + 180_000,
- "45": now + 240_000,
- };
- const res = await toggle({ hour, timestamps });
- try {
- expect(res.status, `bad value: ${String(bad)}`).toBe(400);
- } finally {
- await toggle({ hour }); // ensure clean for next iteration
- }
- }
- });
-
- it("POST toggle rejects action='on' with missing timestamps", async () => {
- // Under the explicit-action contract, the helper would otherwise
- // auto-derive { action: 'off' } for a body with no timestamps. Pass
- // action explicitly so we exercise the on-without-timestamps reject.
- const res = await toggle({ hour: 8, action: "on" });
- expect(res.status).toBe(400);
- });
-
- it("POST toggle rejects missing slot in timestamps object", async () => {
- const res = await toggle({
- hour: 8,
- timestamps: { "0": Date.now() + 60_000, "15": Date.now() + 120_000 },
- });
- expect(res.status).toBe(400);
- });
-
- it("POST toggle rejects non-object timestamps", async () => {
- const res = await toggle({ hour: 8, timestamps: 12345 });
- expect(res.status).toBe(400);
- });
-
- it("POST toggle: a delete does NOT require timestamps", async () => {
- const base = Date.now() + 60 * 60 * 1000;
- const addRes = await toggle({ hour: 11, timestamps: buildTimestamps(base) });
- expect(addRes.status).toBe(200);
- const delRes = await toggle({ hour: 11 });
- expect(delRes.status).toBe(200);
- const body = (await delRes.json()) as { schedule: Record<string, unknown> };
- expect(body.schedule["11"]).toBeUndefined();
- });
-
- it("snapshot reflects multiple marked hours independently with all 4 slots each", async () => {
- const base1 = Date.now() + 2 * 60 * 60 * 1000;
- const base2 = base1 + 60 * 60 * 1000;
- await toggle({ hour: 14, timestamps: buildTimestamps(base1) });
- await toggle({ hour: 19, timestamps: buildTimestamps(base2) });
- const snap = await getSchedule();
- expect(Object.keys(snap.schedule["14"] ?? {}).sort()).toEqual(["0", "15", "30", "45"]);
- expect(Object.keys(snap.schedule["19"] ?? {}).sort()).toEqual(["0", "15", "30", "45"]);
- expect(snap.schedule["14"]?.["0"]).toBe(base1);
- expect(snap.schedule["19"]?.["0"]).toBe(base2);
- // Cleanup so later tests start clean.
- await toggle({ hour: 14 });
- await toggle({ hour: 19 });
- });
-
- it("re-toggling the same hour replaces all 4 slot timestamps", async () => {
- const base1 = Date.now() + 60 * 60 * 1000;
- const base2 = base1 + 30 * 60 * 1000;
- await toggle({ hour: 5, timestamps: buildTimestamps(base1) });
- await toggle({ hour: 5 }); // remove
- const addRes = await toggle({ hour: 5, timestamps: buildTimestamps(base2) });
- const body = (await addRes.json()) as {
- schedule: Record<string, Record<string, number>>;
- };
- expect(body.schedule["5"]?.["0"]).toBe(base2);
- expect(body.schedule["5"]?.["45"]).toBe(base2 + 180_000);
- await toggle({ hour: 5 });
- });
-
- it("snapshot remains consistent across toggle round-trips (persistSchedule atomicity)", async () => {
- // Regression guard for Gemini-review finding #3: persistSchedule
- // originally did DELETE + N INSERTs without a transaction. A mid-loop
- // failure would commit the DELETE and lose the schedule. We can't
- // directly induce a SQLite mid-INSERT failure from here without
- // monkey-patching getDatabase, but we CAN assert that the steady-state
- // round-trip never drops rows — and a transactional impl must agree
- // with itself across GET/POST cycles.
- const base = Date.now() + 60 * 60 * 1000;
- await toggle({ hour: 1, timestamps: buildTimestamps(base) });
- await toggle({ hour: 2, timestamps: buildTimestamps(base + 60_000) });
- await toggle({ hour: 3, timestamps: buildTimestamps(base + 120_000) });
- const snap = await getSchedule();
- for (const h of ["1", "2", "3"]) {
- expect(Object.keys(snap.schedule[h] ?? {}).sort()).toEqual(["0", "15", "30", "45"]);
- }
- // Remove one; the others must be untouched.
- await toggle({ hour: 2 });
- const snap2 = await getSchedule();
- expect(snap2.schedule["1"]).toBeDefined();
- expect(snap2.schedule["2"]).toBeUndefined();
- expect(snap2.schedule["3"]).toBeDefined();
- // Cleanup.
- await toggle({ hour: 1 });
- await toggle({ hour: 3 });
- });
-
- it("POST toggle requires explicit action: 'on' | 'off' (Gemini round-2 #2)", async () => {
- // The server must reject a request that omits `action`. This is the
- // contract that closes the desync-causes-inverted-clicks failure mode:
- // the server is no longer allowed to guess the user's intent from its
- // own (possibly stale-relative-to-UI) in-memory state.
- const now = Date.now();
- const raw = await app.request("/models/wake-schedule/toggle", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ hour: 6, timestamps: buildTimestamps(now + 60_000) }),
- });
- expect(raw.status).toBe(400);
-
- for (const bad of ["toggle", "ON", "OFF", "", true, 1, null]) {
- const res = await app.request("/models/wake-schedule/toggle", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ hour: 6, action: bad, timestamps: buildTimestamps(now + 60_000) }),
- });
- expect(res.status, `bad action: ${JSON.stringify(bad)}`).toBe(400);
- }
- });
-
- it("POST toggle action='off' is idempotent on an already-off hour (no error)", async () => {
- // Stale UI scenario: the UI thinks hour 4 is on and clicks to turn it
- // off, but server state had already deleted it. Must succeed quietly,
- // not 400 or change anything else.
- const before = await getSchedule();
- expect(before.schedule["4"]).toBeUndefined();
- const res = await toggle({ hour: 4 }); // helper auto-derives action='off'
- expect(res.status).toBe(200);
- const body = (await res.json()) as { schedule: Record<string, unknown> };
- expect(body.schedule["4"]).toBeUndefined();
- });
-
- it("POST toggle action='on' on an already-on hour REPLACES timestamps (recovery from desync)", async () => {
- // Stale UI scenario: the UI thinks hour 12 is off and clicks to turn
- // it on, but server state had it already on (from a snapshot the UI
- // missed). Old behavior would have INVERTED the click (turning it
- // off); new behavior replaces the timestamps with the user's freshly
- // computed wall-clock intent and keeps the hour on.
- const base1 = Date.now() + 60 * 60 * 1000;
- const base2 = base1 + 7 * 60 * 60 * 1000;
- const addRes = await toggle({ hour: 12, timestamps: buildTimestamps(base1) });
- expect(addRes.status).toBe(200);
- const reAddRes = await toggle({ hour: 12, timestamps: buildTimestamps(base2) });
- expect(reAddRes.status).toBe(200);
- const body = (await reAddRes.json()) as {
- schedule: Record<string, Record<string, number>>;
- };
- // Hour still present (NOT inverted to off), AND timestamps refreshed.
- expect(body.schedule["12"]?.["0"]).toBe(base2);
- expect(body.schedule["12"]?.["45"]).toBe(base2 + 180_000);
- await toggle({ hour: 12 }); // cleanup
- });
-
- it("POST toggle action='off' ignores timestamps payload (off doesn't need them)", async () => {
- // Accepting extra fields on an off request is fine; we only fail if
- // an action='on' lacks timestamps.
- const base = Date.now() + 60 * 60 * 1000;
- await toggle({ hour: 13, timestamps: buildTimestamps(base) });
- const res = await app.request("/models/wake-schedule/toggle", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ hour: 13, action: "off", timestamps: buildTimestamps(base) }),
- });
- expect(res.status).toBe(200);
- const body = (await res.json()) as { schedule: Record<string, unknown> };
- expect(body.schedule["13"]).toBeUndefined();
- });
-});
-
-describe("GET /models/context-limit", () => {
- it("returns the resolved context limit for a known model", async () => {
- const res = await app.request(
- "/models/context-limit?provider=anthropic&modelId=claude-sonnet-4-5",
- );
- expect(res.status).toBe(200);
- const body = (await res.json()) as { contextLimit: number | null };
- expect(body.contextLimit).toBe(200000);
- });
-
- it("returns null contextLimit for an unknown model", async () => {
- const res = await app.request("/models/context-limit?provider=anthropic&modelId=mystery");
- expect(res.status).toBe(200);
- const body = (await res.json()) as { contextLimit: number | null };
- expect(body.contextLimit).toBeNull();
- });
-
- it("400s when provider or modelId is missing", async () => {
- const res1 = await app.request("/models/context-limit?provider=anthropic");
- expect(res1.status).toBe(400);
- const res2 = await app.request("/models/context-limit?modelId=claude-sonnet-4-5");
- expect(res2.status).toBe(400);
- });
-});
diff --git a/packages/api/tests/wake-scheduler.test.ts b/packages/api/tests/wake-scheduler.test.ts
deleted file mode 100644
index 0e5731c..0000000
--- a/packages/api/tests/wake-scheduler.test.ts
+++ /dev/null
@@ -1,98 +0,0 @@
-import { describe, expect, it } from "vitest";
-import {
- CLAUDE_RESET_OFFSET_HOURS,
- DAILY_INTERVAL_MS,
- MISSED_WAKE_GRACE_MS,
- nextDailyAfter,
- recoverScheduleEntry,
- resetHourFor,
-} from "../src/wake-scheduler.js";
-
-const HOUR = 60 * 60 * 1000;
-
-describe("nextDailyAfter", () => {
- it("returns the input when it is already strictly in the future", () => {
- const now = 1_700_000_000_000;
- const future = now + 60_000;
- expect(nextDailyAfter(future, now)).toBe(future);
- });
-
- it("advances by exactly 24h when the input is 1ms in the past", () => {
- const now = 1_700_000_000_000;
- const previous = now - 1;
- expect(nextDailyAfter(previous, now)).toBe(previous + DAILY_INTERVAL_MS);
- });
-
- it("skips multiple missed days in a single step when far in the past", () => {
- const now = 1_700_000_000_000;
- const previous = now - 3 * DAILY_INTERVAL_MS - HOUR; // 3d 1h ago
- const next = nextDailyAfter(previous, now);
- expect(next).toBeGreaterThan(now);
- // Should be the next 24h-multiple boundary, not just +24h.
- expect((next - previous) % DAILY_INTERVAL_MS).toBe(0);
- expect(next - previous).toBe(4 * DAILY_INTERVAL_MS);
- });
-
- it("returns previous + 1 day exactly when previous == now", () => {
- const now = 1_700_000_000_000;
- expect(nextDailyAfter(now, now)).toBe(now + DAILY_INTERVAL_MS);
- });
-});
-
-describe("recoverScheduleEntry", () => {
- const now = 1_700_000_000_000;
-
- it("leaves a future entry unchanged and does not fire", () => {
- const stored = now + 5 * HOUR;
- expect(recoverScheduleEntry(stored, now)).toEqual({
- nextWakeAt: stored,
- shouldFireNow: false,
- });
- });
-
- it("fires now for an entry missed by less than the grace window", () => {
- const stored = now - HOUR; // 1h ago, within 2h grace
- const recovered = recoverScheduleEntry(stored, now);
- expect(recovered.shouldFireNow).toBe(true);
- expect(recovered.nextWakeAt).toBeGreaterThan(now);
- });
-
- it("fires now for an entry missed by exactly the grace window", () => {
- const stored = now - MISSED_WAKE_GRACE_MS;
- expect(recoverScheduleEntry(stored, now).shouldFireNow).toBe(true);
- });
-
- it("does NOT fire for an entry missed by more than the grace window", () => {
- const stored = now - MISSED_WAKE_GRACE_MS - 1;
- const recovered = recoverScheduleEntry(stored, now);
- expect(recovered.shouldFireNow).toBe(false);
- expect(recovered.nextWakeAt).toBeGreaterThan(now);
- });
-
- it("always returns a future nextWakeAt for past entries (regardless of grace)", () => {
- for (const ageDays of [0.1, 0.5, 1, 2, 7, 30]) {
- const stored = now - ageDays * DAILY_INTERVAL_MS;
- const { nextWakeAt } = recoverScheduleEntry(stored, now);
- expect(nextWakeAt, `age=${ageDays}d`).toBeGreaterThan(now);
- }
- });
-
- it("respects a custom grace window", () => {
- const stored = now - 10 * 60_000; // 10 min ago
- expect(recoverScheduleEntry(stored, now, 5 * 60_000).shouldFireNow).toBe(false);
- expect(recoverScheduleEntry(stored, now, 15 * 60_000).shouldFireNow).toBe(true);
- });
-});
-
-describe("resetHourFor / CLAUDE_RESET_OFFSET_HOURS", () => {
- it("adds the offset modulo 24", () => {
- expect(resetHourFor(0)).toBe(CLAUDE_RESET_OFFSET_HOURS);
- expect(resetHourFor(20)).toBe((20 + CLAUDE_RESET_OFFSET_HOURS) % 24);
- expect(resetHourFor(23)).toBe((23 + CLAUDE_RESET_OFFSET_HOURS) % 24);
- });
-
- it("wraps cleanly across midnight", () => {
- // Wake at 22:15, +5h = 03:00
- expect(resetHourFor(22)).toBe(3);
- });
-});
diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json
deleted file mode 100644
index 2d6fedd..0000000
--- a/packages/api/tsconfig.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
- "extends": "../../tsconfig.base.json",
- "compilerOptions": {
- "outDir": "dist",
- "rootDir": "src",
- "types": ["@types/bun"]
- },
- "include": ["src/**/*.ts"],
- "exclude": ["node_modules", "dist", "tests"]
-}
diff --git a/packages/api/vitest.config.ts b/packages/api/vitest.config.ts
deleted file mode 100644
index 854422e..0000000
--- a/packages/api/vitest.config.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { defineConfig } from "vitest/config";
-
-export default defineConfig({
- test: {
- include: ["tests/**/*.test.ts"],
- server: {
- deps: {
- inline: ["zod", "@dispatch/core"],
- },
- },
- },
-});
diff --git a/packages/core/package.json b/packages/core/package.json
deleted file mode 100644
index 55dff0f..0000000
--- a/packages/core/package.json
+++ /dev/null
@@ -1,29 +0,0 @@
-{
- "name": "@dispatch/core",
- "version": "0.0.1",
- "private": true,
- "type": "module",
- "main": "src/index.ts",
- "types": "src/index.ts",
- "scripts": {
- "test": "vitest run",
- "test:watch": "vitest",
- "typecheck": "tsc --noEmit"
- },
- "dependencies": {
- "@ai-sdk/anthropic": "^3.0.79",
- "@ai-sdk/openai-compatible": "^2.0.48",
- "ai": "^6.0.191",
- "chokidar": "^5.0.0",
- "smol-toml": "^1.6.1",
- "tree-sitter-bash": "^0.25.1",
- "vscode-jsonrpc": "8.2.1",
- "vscode-languageserver-types": "3.17.5",
- "web-tree-sitter": "^0.26.8",
- "zod": "^3.23.0",
- "zod-to-json-schema": "^3.25.2"
- },
- "devDependencies": {
- "@types/bun": "latest"
- }
-}
diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts
deleted file mode 100644
index 2e2dbb2..0000000
--- a/packages/core/src/agent/agent.ts
+++ /dev/null
Binary files differ
diff --git a/packages/core/src/agents/index.ts b/packages/core/src/agents/index.ts
deleted file mode 100644
index 4931162..0000000
--- a/packages/core/src/agents/index.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-export {
- deleteAgent,
- expandAgentToolNames,
- GLOBAL_AGENTS_DIR,
- getAgentDirPaths,
- getAgentDirs,
- getProjectAgentsDir,
- loadAgent,
- loadAgents,
- saveAgent,
-} from "./loader.js";
diff --git a/packages/core/src/agents/loader.ts b/packages/core/src/agents/loader.ts
deleted file mode 100644
index 9971803..0000000
--- a/packages/core/src/agents/loader.ts
+++ /dev/null
@@ -1,294 +0,0 @@
-import * as fs from "node:fs";
-import * as os from "node:os";
-import * as path from "node:path";
-import { parse as parseTOML, stringify as stringifyTOML } from "smol-toml";
-import { type AgentDefinition, type AgentModelEntry, isReasoningEffort } from "../types/index.js";
-
-// ─── Helpers ─────────────────────────────────────────────────────
-
-/** Sanitize a slug to prevent path traversal */
-function sanitizeSlug(slug: string): string {
- // Strip directory components and ensure only safe characters
- const base = path.basename(slug);
- const clean = base
- .replace(/[^a-zA-Z0-9_-]/g, "-")
- .replace(/-+/g, "-")
- .replace(/^-|-$/g, "");
- if (!clean) throw new Error("Invalid agent slug");
- return clean;
-}
-
-// ─── Constants ───────────────────────────────────────────────────
-
-export const GLOBAL_AGENTS_DIR = path.join(os.homedir(), ".config", "dispatch", "agents");
-
-export function getProjectAgentsDir(projectDir: string): string {
- return path.join(projectDir, ".dispatch", "agents");
-}
-
-// ─── Public API ──────────────────────────────────────────────────
-
-/**
- * Returns the agent directories that exist or could exist.
- * Always includes global. Includes project dir if projectDir is provided.
- */
-export function getAgentDirs(
- projectDir?: string,
-): Array<{ label: string; path: string; scope: string }> {
- const dirs: Array<{ label: string; path: string; scope: string }> = [
- { label: "Global (~/.config/dispatch/agents)", path: GLOBAL_AGENTS_DIR, scope: "global" },
- ];
- if (projectDir) {
- dirs.push({
- label: `.dispatch/agents (${path.basename(projectDir)})`,
- path: getProjectAgentsDir(projectDir),
- scope: projectDir,
- });
- }
- return dirs;
-}
-
-/**
- * Return just the absolute filesystem paths of the agent directories.
- * Used by the agent's permission gate to grant read-only access to
- * these locations by default (so any agent can list/read agent
- * definitions without prompting the user).
- */
-export function getAgentDirPaths(projectDir?: string): string[] {
- const paths = [GLOBAL_AGENTS_DIR];
- if (projectDir) paths.push(getProjectAgentsDir(projectDir));
- return paths;
-}
-
-/**
- * Load a single agent definition by slug. Searches the project-scoped
- * directory first (if `projectDir` is provided), then falls back to
- * the global directory. Returns `null` if no match is found.
- *
- * Slug matching is exact and case-sensitive; sanitization mirrors
- * `saveAgent` to keep loader and writer symmetric.
- */
-export function loadAgent(slug: string, projectDir?: string): AgentDefinition | null {
- const safeSlug = sanitizeSlug(slug);
- const agents = loadAgents(projectDir);
- return agents.find((a) => a.slug === safeSlug) ?? null;
-}
-
-/**
- * Translate the short permission-group names used by `AgentDefinition.tools`
- * (e.g. `"read"`, `"edit"`, `"bash"`) into the concrete tool-implementation
- * names registered with the agent runtime (e.g. `"read_file"`,
- * `"list_files"`, `"write_file"`, `"run_shell"`).
- *
- * The mapping mirrors the per-permission tool-creation paths in
- * `AgentManager.getOrCreateAgentForTab` so a subagent summoned with a
- * given agent definition ends up with the exact same set of registered
- * tools as a top-level tab using that definition. Tool names that aren't
- * group aliases (`summon`, `retrieve`, `web_search`, `youtube_transcribe`,
- * `todo`) are passed through unchanged.
- *
- * `"todo"` is auto-included so the summoned agent always has its task list
- * available, matching the parent-agent path which always registers `todo`.
- */
-export function expandAgentToolNames(tools: string[]): string[] {
- const expanded = new Set<string>();
- for (const t of tools) {
- switch (t) {
- case "read":
- expanded.add("read_file");
- expanded.add("read_file_slice");
- expanded.add("list_files");
- break;
- case "edit":
- expanded.add("write_file");
- break;
- case "bash":
- expanded.add("run_shell");
- break;
- default:
- // Pass through tool names that aren't permission-group
- // aliases (summon, retrieve, web_search, youtube_transcribe,
- // send_to_tab, read_tab, todo, and the granular file tools
- // themselves if a user hand-wrote them in a TOML).
- expanded.add(t);
- }
- }
- // Always include `todo` — every agent should be able to track its work,
- // and the parent-agent path adds it unconditionally.
- expanded.add("todo");
- return Array.from(expanded);
-}
-
-/**
- * Ensure the default global agent exists. Creates it if missing.
- */
-function ensureDefaultAgent(): void {
- const filePath = path.join(GLOBAL_AGENTS_DIR, "default.toml");
- if (fs.existsSync(filePath)) return;
-
- const defaultAgent: AgentDefinition = {
- name: "Default",
- description: "Default agent with all tools enabled",
- skills: [],
- tools: ["read", "edit", "bash", "summon"],
- models: [],
- scope: "global",
- slug: "default",
- };
- saveAgent(defaultAgent);
-}
-
-/**
- * Load all agent definitions from global + project directories.
- * Auto-generates the default global agent if it doesn't exist.
- */
-export function loadAgents(projectDir?: string): AgentDefinition[] {
- ensureDefaultAgent();
-
- const agents: AgentDefinition[] = [];
-
- // Global agents
- agents.push(...loadAgentsFromDir(GLOBAL_AGENTS_DIR, "global"));
-
- // Project-scoped agents
- if (projectDir) {
- agents.push(...loadAgentsFromDir(getProjectAgentsDir(projectDir), projectDir));
- }
-
- return agents;
-}
-
-/**
- * Save (create or update) an agent definition to a TOML file.
- * The scope determines which directory:
- * - "global" -> ~/.config/dispatch/agents/
- * - any other string -> that directory path + /.dispatch/agents/
- */
-export function saveAgent(agent: AgentDefinition): void {
- if (agent.scope !== "global" && agent.scope.includes("..")) {
- throw new Error("Invalid agent scope");
- }
- const dir = agent.scope === "global" ? GLOBAL_AGENTS_DIR : getProjectAgentsDir(agent.scope);
-
- fs.mkdirSync(dir, { recursive: true });
-
- const tomlContent: Record<string, unknown> = {
- name: agent.name,
- description: agent.description,
- skills: agent.skills,
- tools: agent.tools,
- };
-
- if (agent.cwd) {
- tomlContent.cwd = agent.cwd;
- }
-
- if (agent.is_subagent) {
- tomlContent.is_subagent = true;
- }
-
- // smol-toml handles [[models]] array-of-tables
- if (agent.models.length > 0) {
- tomlContent.models = agent.models.map((m) => ({
- key_id: m.key_id,
- model_id: m.model_id,
- ...(m.effort ? { effort: m.effort } : {}),
- }));
- }
-
- const content = stringifyTOML(tomlContent);
- const safeSlug = sanitizeSlug(agent.slug);
- const filePath = path.join(dir, `${safeSlug}.toml`);
- fs.writeFileSync(filePath, content, "utf-8");
-}
-
-/**
- * Delete an agent TOML file.
- */
-export function deleteAgent(slug: string, scope: string): boolean {
- if (scope !== "global" && scope.includes("..")) {
- throw new Error("Invalid agent scope");
- }
- const dir = scope === "global" ? GLOBAL_AGENTS_DIR : getProjectAgentsDir(scope);
-
- const safeSlug = sanitizeSlug(slug);
- const filePath = path.join(dir, `${safeSlug}.toml`);
- if (fs.existsSync(filePath)) {
- fs.unlinkSync(filePath);
- return true;
- }
- return false;
-}
-
-// ─── Internal ────────────────────────────────────────────────────
-
-function loadAgentsFromDir(dir: string, scope: string): AgentDefinition[] {
- if (!fs.existsSync(dir)) return [];
-
- const results: AgentDefinition[] = [];
- let entries: fs.Dirent[];
- try {
- entries = fs.readdirSync(dir, { withFileTypes: true });
- } catch {
- return [];
- }
-
- for (const entry of entries) {
- if (!entry.isFile() || !entry.name.endsWith(".toml")) continue;
-
- const filePath = path.join(dir, entry.name);
- const slug = entry.name.slice(0, -5); // remove .toml
-
- try {
- const raw = fs.readFileSync(filePath, "utf-8");
- const parsed = parseTOML(raw);
-
- const models: AgentModelEntry[] = [];
- if (Array.isArray(parsed.models)) {
- for (const m of parsed.models) {
- if (m && typeof m === "object" && "key_id" in m && "model_id" in m) {
- const rawEffort = (m as Record<string, unknown>).effort;
- models.push({
- key_id: String((m as Record<string, unknown>).key_id),
- model_id: String((m as Record<string, unknown>).model_id),
- // Only carry `effort` when it's a recognised level; an
- // unset or invalid value falls back to the per-tab /
- // default effort at the call site.
- ...(isReasoningEffort(rawEffort) ? { effort: rawEffort } : {}),
- });
- }
- }
- }
-
- const skills: string[] = [];
- if (Array.isArray(parsed.skills)) {
- for (const s of parsed.skills) {
- if (typeof s === "string") skills.push(s);
- }
- }
-
- const tools: string[] = [];
- if (Array.isArray(parsed.tools)) {
- for (const t of parsed.tools) {
- if (typeof t === "string") tools.push(t);
- }
- }
-
- results.push({
- name: typeof parsed.name === "string" ? parsed.name : slug,
- description: typeof parsed.description === "string" ? parsed.description : "",
- skills,
- tools,
- models,
- scope,
- slug,
- ...(typeof parsed.cwd === "string" && parsed.cwd ? { cwd: parsed.cwd } : {}),
- ...(parsed.is_subagent === true ? { is_subagent: true } : {}),
- });
- } catch {
- // Skip unparseable files
- }
- }
-
- return results;
-}
diff --git a/packages/core/src/chunks/append.ts b/packages/core/src/chunks/append.ts
deleted file mode 100644
index 4ca6fe1..0000000
--- a/packages/core/src/chunks/append.ts
+++ /dev/null
@@ -1,314 +0,0 @@
-/**
- * Chunk-builder helper.
- *
- * `appendEventToChunks` is the single source of truth for how a stream of
- * `AgentEvent`s collapses into an ordered `Chunk[]` on a message. Both the
- * backend (agent + agent-manager) and the frontend store call this helper
- * so the wire format stays in lockstep across the boundary.
- *
- * Open/close rules — see notes/plan-chunk-refactor.md for the full table.
- *
- * | Chunk | Opens on | Coalesces |
- * |---------------|-----------------------------------------------------------------|------------------------------------------------------------|
- * | `text` | first `text-delta` after a non-text chunk | consecutive `text-delta` events append to `.text` |
- * | `thinking` | first `reasoning-delta` after a non-thinking chunk | consecutive `reasoning-delta` events append to `.text` |
- * | | OR after the last thinking chunk was sealed by `reasoning-end` | (only into the most recent UNSEALED thinking chunk) |
- * | `tool-batch` | first `tool-call` after a non-tool-batch chunk | consecutive `tool-call` events push a new entry to `.calls`|
- * | `error` | every `error` event | NEVER (always single-event) |
- * | `system` | every `notice`/`model-changed`/`config-reload`/... | NEVER (two consecutive system events → two chunks) |
- *
- * Side-effect events (no new chunk):
- * - `tool-result` → finds the call by `id` across all `tool-batch`
- * chunks (most-recent first) and updates its
- * `result` / `isError`.
- * - `shell-output` → appends to the most recent entry of the most
- * recent `tool-batch` chunk.
- * - `reasoning-end` → attaches `metadata` (the AI SDK v6
- * `providerMetadata` blob) to the most recent
- * UNSEALED `thinking` chunk. The metadata is also
- * the "sealed" marker — subsequent
- * `reasoning-delta`s will open a new chunk rather
- * than extending this one. Anthropic's signature
- * lives inside this blob; round-tripping it on the
- * next turn is mandatory for Anthropic to accept
- * the conversation. Orphan `reasoning-end` events
- * (no unsealed thinking chunk) are dropped.
- *
- * Ignored events:
- * - `status`, `turn-start`, `turn-sealed`, `done`, `usage`,
- * `task-list-update`, `tab-created`, `message-queued`, `message-consumed`,
- * `message-cancelled` — these are control / lifecycle events, not message
- * content.
- */
-
-import type {
- AgentEvent,
- ChatMessage,
- Chunk,
- MessageRole,
- SystemChunk,
- SystemChunkKind,
- ToolBatchChunk,
-} from "../types/index.js";
-
-/**
- * Mutates `chunks` in place based on `event`.
- *
- * Returns void; the array is the output channel.
- */
-export function appendEventToChunks(chunks: Chunk[], event: AgentEvent): void {
- switch (event.type) {
- case "text-delta": {
- // Open or extend the current text chunk.
- const last = chunks[chunks.length - 1];
- if (last && last.type === "text") {
- last.text += event.delta;
- } else {
- chunks.push({ type: "text", text: event.delta });
- }
- return;
- }
-
- case "reasoning-delta": {
- // Open a new thinking chunk if the last chunk is not a thinking
- // chunk OR if it's already sealed by metadata. Anthropic emits
- // each thinking content block with its own metadata; a fresh
- // reasoning-delta after a sealed thinking chunk is the start of
- // a new block, not a continuation — extending the sealed chunk
- // would corrupt the metadata/text mapping.
- const last = chunks[chunks.length - 1];
- if (last && last.type === "thinking" && last.metadata === undefined) {
- last.text += event.delta;
- } else {
- chunks.push({ type: "thinking", text: event.delta });
- }
- return;
- }
-
- case "reasoning-end": {
- // Attach `providerMetadata` to the most recent unsealed
- // thinking chunk. Anthropic's signature lives inside this
- // blob; without it on the next request, Anthropic rejects the
- // thinking block. The walk-back is a defensive backstop —
- // Anthropic's SSE delivers a content block's deltas strictly
- // in order and `appendEventToChunks` runs synchronously per
- // event, so the most recent thinking chunk is normally the
- // last chunk in the array.
- if (event.metadata === undefined) return;
- for (let i = chunks.length - 1; i >= 0; i--) {
- const c = chunks[i];
- if (!c || c.type !== "thinking") continue;
- if (c.metadata !== undefined) {
- // Already sealed; the orphan metadata has no home.
- return;
- }
- c.metadata = event.metadata;
- return;
- }
- // No thinking chunk found at all — drop silently.
- return;
- }
-
- case "tool-call": {
- // Open or extend the current tool-batch chunk.
- const last = chunks[chunks.length - 1];
- const entry = {
- id: event.toolCall.id,
- name: event.toolCall.name,
- arguments: event.toolCall.arguments,
- };
- if (last && last.type === "tool-batch") {
- last.calls.push(entry);
- } else {
- chunks.push({ type: "tool-batch", calls: [entry] });
- }
- return;
- }
-
- case "tool-result": {
- // Find the matching call (by id) across all tool-batch chunks,
- // most-recent first. Tool results can arrive after subsequent
- // text-deltas, so we cannot rely on the *last* chunk being the
- // tool-batch — we have to search.
- for (let i = chunks.length - 1; i >= 0; i--) {
- const c = chunks[i];
- if (!c || c.type !== "tool-batch") continue;
- const call = c.calls.find((e) => e.id === event.toolResult.toolCallId);
- if (call) {
- call.result = event.toolResult.result;
- call.isError = event.toolResult.isError;
- return;
- }
- }
- // Orphan result with no matching call — drop silently.
- return;
- }
-
- case "shell-output": {
- // Append to the most recent entry of the most recent tool-batch.
- // Walk back through chunks to find the latest tool-batch; if there
- // are intervening text/thinking/etc chunks (which can happen if
- // the model streams text while a shell tool is still running),
- // we still want the most recent tool-batch.
- for (let i = chunks.length - 1; i >= 0; i--) {
- const c = chunks[i];
- if (!c || c.type !== "tool-batch") continue;
- const entry = c.calls[c.calls.length - 1];
- if (!entry) return;
- const prev = entry.shellOutput ?? { stdout: "", stderr: "" };
- entry.shellOutput = {
- stdout: prev.stdout + (event.stream === "stdout" ? event.data : ""),
- stderr: prev.stderr + (event.stream === "stderr" ? event.data : ""),
- };
- return;
- }
- // Orphan shell-output with no tool-batch in scope — drop silently.
- return;
- }
-
- case "error": {
- // Always a fresh single-event chunk — no coalescing.
- chunks.push({
- type: "error",
- message: event.error,
- ...(event.statusCode !== undefined ? { statusCode: event.statusCode } : {}),
- });
- return;
- }
-
- case "notice": {
- chunks.push({ type: "system", kind: "notice", text: event.message });
- return;
- }
-
- case "model-changed": {
- chunks.push({
- type: "system",
- kind: "model-changed",
- text: `Switched to ${event.modelId} (${event.keyId})`,
- });
- return;
- }
-
- case "config-reload": {
- chunks.push({
- type: "system",
- kind: "config-reload",
- text: "Configuration reloaded",
- });
- return;
- }
-
- // Lifecycle / control events — no chunk emitted.
- case "status":
- case "turn-start":
- case "turn-sealed":
- case "done":
- case "usage":
- case "task-list-update":
- case "tab-created":
- case "message-queued":
- case "message-consumed":
- case "message-cancelled":
- case "compaction-started":
- case "compaction-complete":
- case "compaction-error":
- return;
-
- default: {
- // Exhaustiveness check — if a new event variant is added to
- // AgentEvent, TypeScript will complain here.
- const _exhaustive: never = event;
- void _exhaustive;
- return;
- }
- }
-}
-
-// ─── System event routing across messages ────────────────────────
-
-/**
- * Minimal shape needed by `applySystemEvent`.
- *
- * The caller (agent-manager / persistence layer) typically tracks message
- * id alongside the wire-format `ChatMessage`. This generic constraint
- * lets us keep core `ChatMessage` clean while still letting downstream
- * pass anything with an `id`.
- */
-export interface IdentifiedMessage {
- id: string;
- role: MessageRole;
- chunks: Chunk[];
-}
-
-/**
- * Describes the system event in caller-controlled terms. We let the caller
- * decide both the `kind` (so the same helper can record cancellations,
- * notices, model swaps, etc.) and the `text` (so the caller controls
- * formatting / localization).
- */
-export interface SystemEventLike {
- kind: SystemChunkKind;
- text: string;
-}
-
-/**
- * Routes a system event to the right message when *no assistant turn is
- * in flight*. (When a turn IS in flight, the caller should instead use
- * `appendEventToChunks` against the in-flight message's chunks directly.)
- *
- * Routing rules (from notes/plan-chunk-refactor.md):
- *
- * 1. Most recent message is `role: "system"` → append a `system` chunk
- * to it. (Note: a second consecutive system event creates a second
- * system chunk inside the same system message — chunks themselves
- * never coalesce.)
- * 2. Otherwise → create a fresh `role: "system"` message containing one
- * `system` chunk and push it.
- *
- * Returns the `messageId` that was used (either the existing system
- * message's id or the newly-created one) so the caller can persist /
- * emit a diff to subscribers.
- *
- * `idFactory` defaults to `crypto.randomUUID()`; tests inject a
- * deterministic factory.
- */
-export function applySystemEvent<M extends IdentifiedMessage>(
- messages: M[],
- event: SystemEventLike,
- idFactory: () => string = defaultIdFactory,
-): { messageId: string } {
- const chunk: SystemChunk = { type: "system", kind: event.kind, text: event.text };
-
- const last = messages[messages.length - 1];
- if (last && last.role === "system") {
- last.chunks.push(chunk);
- return { messageId: last.id };
- }
-
- const id = idFactory();
- // We can't fabricate the full `M` shape without knowing its extra
- // fields, but `IdentifiedMessage` is the minimum we need to push.
- // Callers that extend the shape with extra fields are responsible for
- // initializing them via post-hoc patching, or by passing in their own
- // message-creation logic. In practice callers either:
- // (a) use `ChatMessage` itself (no extra fields beyond IdentifiedMessage), or
- // (b) construct messages and look them up by id after this call returns.
- const newMessage = { id, role: "system" as const, chunks: [chunk] } as unknown as M;
- messages.push(newMessage);
- return { messageId: id };
-}
-
-function defaultIdFactory(): string {
- // In Node 19+ / modern browsers, `crypto.randomUUID` is available globally.
- if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
- return crypto.randomUUID();
- }
- // Fallback: pseudo-random; not cryptographically secure, but adequate for
- // in-memory message identifiers when randomUUID is unavailable.
- return `sysmsg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
-}
-
-// ─── Re-exports for convenience ──────────────────────────────────
-
-export type { ChatMessage, Chunk, SystemChunk, SystemChunkKind, ToolBatchChunk };
diff --git a/packages/core/src/chunks/transform.ts b/packages/core/src/chunks/transform.ts
deleted file mode 100644
index e8f4a18..0000000
--- a/packages/core/src/chunks/transform.ts
+++ /dev/null
@@ -1,271 +0,0 @@
-// Pure, dependency-free transforms between the render-shaped `Chunk[]` /
-// `ChatMessage` model and the flat append-only `ChunkRow` log. Kept free of any
-// DB (`bun:sqlite`) import so BOTH the backend persistence layer
-// (`db/chunks.ts`) and the browser frontend store can share the exact same
-// explode/group logic.
-
-import type {
- Chunk,
- ChunkRow,
- ChunkRowDraft,
- ErrorData,
- MessageRole,
- SystemData,
- TextData,
- ThinkingData,
- ToolBatchChunk,
- ToolCallData,
- ToolResultData,
-} from "../types/index.js";
-
-/**
- * A DERIVED message — a grouping of contiguous chunk rows reconstructed for the
- * agent's in-memory history and for the frontend's render bubbles. NOT a stored
- * shape: the source of truth is the flat chunk log.
- */
-export interface MessageRow {
- id: string;
- tabId: string;
- seq: number;
- /** turn_id of the chunk rows this message was grouped from. */
- turnId: string;
- role: MessageRole;
- chunks: Chunk[];
- createdAt: number;
-}
-
-// ─── Explode: in-memory turn → flat chunk-row drafts ─────────────
-//
-// A turn's render-shaped `Chunk[]` is flattened into append-only rows.
-// `tool-batch` chunks are split into SEPARATE `tool_call` (role=assistant) and
-// `tool_result` (role=tool) rows linked by `callId`, mapping 1:1 to the
-// Anthropic wire format. `step` increments after each tool-batch: every LLM
-// round-trip emits text/thinking then (optionally) one tool-batch, so a
-// tool-batch boundary is exactly a step boundary.
-
-/** Explode a single user message's text into one row draft. */
-export function explodeUserText(turnId: string, text: string): ChunkRowDraft[] {
- return [{ turnId, step: 0, role: "user", type: "text", data: { text } }];
-}
-
-/** Explode an assistant turn's accumulated chunks into ordered row drafts. */
-export function explodeTurn(turnId: string, chunks: Chunk[]): ChunkRowDraft[] {
- const drafts: ChunkRowDraft[] = [];
- let step = 0;
- for (const chunk of chunks) {
- switch (chunk.type) {
- case "text":
- drafts.push({ turnId, step, role: "assistant", type: "text", data: { text: chunk.text } });
- break;
- case "thinking":
- drafts.push({
- turnId,
- step,
- role: "assistant",
- type: "thinking",
- data: {
- text: chunk.text,
- ...(chunk.metadata !== undefined ? { metadata: chunk.metadata } : {}),
- },
- });
- break;
- case "tool-batch": {
- for (const call of chunk.calls) {
- drafts.push({
- turnId,
- step,
- role: "assistant",
- type: "tool_call",
- data: { callId: call.id, name: call.name, arguments: call.arguments },
- });
- }
- for (const call of chunk.calls) {
- if (call.result === undefined) continue;
- drafts.push({
- turnId,
- step,
- role: "tool",
- type: "tool_result",
- data: {
- callId: call.id,
- name: call.name,
- result: call.result,
- isError: call.isError ?? false,
- ...(call.shellOutput !== undefined ? { shellOutput: call.shellOutput } : {}),
- },
- });
- }
- // A tool-batch ends the current LLM step; subsequent chunks belong
- // to the next round-trip.
- step++;
- break;
- }
- case "error":
- drafts.push({
- turnId,
- step,
- role: "assistant",
- type: "error",
- data: {
- message: chunk.message,
- ...(chunk.statusCode !== undefined ? { statusCode: chunk.statusCode } : {}),
- },
- });
- break;
- case "system":
- drafts.push({
- turnId,
- step,
- role: "system",
- type: "system",
- data: { kind: chunk.kind, text: chunk.text },
- });
- break;
- }
- }
- return drafts;
-}
-
-// ─── Group: flat chunk rows → derived render messages ────────────
-//
-// The inverse of explode (best-effort over an arbitrary window, so it tolerates
-// orphan tool-results whose tool-call was paged out). `tool_result` rows
-// (role=tool) merge back into the preceding assistant message's per-step
-// `tool-batch` chunk by `callId` rather than forming their own message.
-
-export function groupRowsToMessages(rows: ChunkRow[]): MessageRow[] {
- const messages: MessageRow[] = [];
-
- let current: { msg: MessageRow; batches: Map<number, ToolBatchChunk> } | null = null;
- const flush = () => {
- if (current) {
- messages.push(current.msg);
- current = null;
- }
- };
- const ensureAssistant = (row: ChunkRow) => {
- if (!current) {
- current = {
- msg: {
- id: row.id,
- tabId: row.tabId,
- seq: row.seq,
- turnId: row.turnId,
- role: "assistant",
- chunks: [],
- createdAt: row.createdAt,
- },
- batches: new Map(),
- };
- }
- return current;
- };
- const ensureBatch = (step: number): ToolBatchChunk => {
- const c = current;
- if (!c) throw new Error("ensureBatch called without an assistant message");
- let batch = c.batches.get(step);
- if (!batch) {
- batch = { type: "tool-batch", calls: [] };
- c.batches.set(step, batch);
- c.msg.chunks.push(batch);
- }
- return batch;
- };
-
- for (const row of rows) {
- if (row.role === "user") {
- flush();
- const d = row.data as TextData;
- messages.push({
- id: row.id,
- tabId: row.tabId,
- seq: row.seq,
- turnId: row.turnId,
- role: "user",
- chunks: [{ type: "text", text: d.text }],
- createdAt: row.createdAt,
- });
- continue;
- }
- if (row.role === "system") {
- // Coalesce consecutive system rows into one system message (multiple
- // system chunks), matching the old applySystemEvent behaviour.
- const prev = messages[messages.length - 1];
- const d = row.data as SystemData;
- if (current === null && prev && prev.role === "system") {
- prev.chunks.push({ type: "system", kind: d.kind, text: d.text });
- continue;
- }
- flush();
- messages.push({
- id: row.id,
- tabId: row.tabId,
- seq: row.seq,
- turnId: row.turnId,
- role: "system",
- chunks: [{ type: "system", kind: d.kind, text: d.text }],
- createdAt: row.createdAt,
- });
- continue;
- }
-
- // Usage rows are an invisible side channel (persisted for the backend
- // aggregate only). They're already query-excluded from getChunksForTab,
- // so this is defensive insurance: never let one leak into render grouping.
- if (row.type === "usage") continue;
-
- // assistant / tool rows → part of the current assistant message
- const c = ensureAssistant(row);
- switch (row.type) {
- case "text":
- c.msg.chunks.push({ type: "text", text: (row.data as TextData).text });
- break;
- case "thinking": {
- const d = row.data as ThinkingData;
- c.msg.chunks.push({
- type: "thinking",
- text: d.text,
- ...(d.metadata !== undefined ? { metadata: d.metadata } : {}),
- });
- break;
- }
- case "error": {
- const d = row.data as ErrorData;
- c.msg.chunks.push({
- type: "error",
- message: d.message,
- ...(d.statusCode !== undefined ? { statusCode: d.statusCode } : {}),
- });
- break;
- }
- case "tool_call": {
- const d = row.data as ToolCallData;
- ensureBatch(row.step).calls.push({ id: d.callId, name: d.name, arguments: d.arguments });
- break;
- }
- case "tool_result": {
- const d = row.data as ToolResultData;
- const batch = ensureBatch(row.step);
- const entry = batch.calls.find((e) => e.id === d.callId);
- if (entry) {
- entry.result = d.result;
- entry.isError = d.isError;
- if (d.shellOutput !== undefined) entry.shellOutput = d.shellOutput;
- } else {
- // Orphan result (its tool_call was paged out of this window).
- batch.calls.push({
- id: d.callId,
- name: d.name,
- arguments: {},
- result: d.result,
- isError: d.isError,
- ...(d.shellOutput !== undefined ? { shellOutput: d.shellOutput } : {}),
- });
- }
- break;
- }
- }
- }
- flush();
- return messages;
-}
diff --git a/packages/core/src/compaction/index.ts b/packages/core/src/compaction/index.ts
deleted file mode 100644
index 663ccb2..0000000
--- a/packages/core/src/compaction/index.ts
+++ /dev/null
@@ -1,245 +0,0 @@
-// Conversation compaction — summarize the older "head" of a conversation into
-// a structured anchor while preserving the most recent turns verbatim.
-//
-// Ported from opencode's `session/compaction.ts` (SUMMARY_TEMPLATE, the
-// anchored-summary `buildPrompt`, and the `TOOL_OUTPUT_MAX_CHARS` cap). The
-// turn-budget `splitTurn` tail selection is intentionally simplified to a fixed
-// recent-turn count (`DEFAULT_TAIL_TURNS`) — Dispatch keeps the last N turns
-// verbatim rather than computing a token budget.
-//
-// This module is pure and DB-free so it can be unit-tested in isolation and
-// shared by the API orchestrator.
-
-import type { ChatMessage } from "../types/index.js";
-
-/** Number of trailing turns kept verbatim (opencode's DEFAULT_TAIL_TURNS). */
-export const DEFAULT_TAIL_TURNS = 2;
-
-/** Max characters of a single tool result fed into the summary request. */
-export const TOOL_OUTPUT_MAX_CHARS = 2_000;
-
-/**
- * Marker prefixing the seeded summary turn in a compacted conversation. Lets a
- * subsequent compaction detect a prior summary and re-summarize (anchor) it
- * instead of treating it as ordinary conversation.
- */
-export const SUMMARY_MARKER = "[CONVERSATION SUMMARY]";
-
-/**
- * Structured Markdown template the summary must follow. Ported verbatim from
- * opencode's `SUMMARY_TEMPLATE`.
- */
-export const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
-<template>
-## Goal
-- [single-sentence task summary]
-
-## Constraints & Preferences
-- [user constraints, preferences, specs, or "(none)"]
-
-## Progress
-### Done
-- [completed work or "(none)"]
-
-### In Progress
-- [current work or "(none)"]
-
-### Blocked
-- [blockers or "(none)"]
-
-## Key Decisions
-- [decision and why, or "(none)"]
-
-## Next Steps
-- [ordered next actions or "(none)"]
-
-## Critical Context
-- [important technical facts, errors, open questions, or "(none)"]
-
-## Relevant Files
-- [file or directory path: why it matters, or "(none)"]
-</template>
-
-Rules:
-- Keep every section, even when empty.
-- Use terse bullets, not prose paragraphs.
-- Preserve exact file paths, commands, error strings, and identifiers when known.
-- Do not mention the summary process or that context was compacted.`;
-
-/**
- * Build the compaction instruction. When `previousSummary` is provided, the
- * model is asked to UPDATE the anchored summary rather than create a fresh one
- * (opencode's `buildPrompt` anchor behaviour).
- */
-export function buildCompactionPrompt(input: { previousSummary?: string }): string {
- const anchor = input.previousSummary
- ? [
- "Update the anchored summary below using the conversation history above.",
- "Preserve still-true details, remove stale details, and merge in the new facts.",
- "<previous-summary>",
- input.previousSummary,
- "</previous-summary>",
- ].join("\n")
- : "Create a new anchored summary from the conversation history above.";
- return `${anchor}\n\n${SUMMARY_TEMPLATE}`;
-}
-
-/**
- * The first text chunk of a message, trimmed (empty → undefined). Used to read
- * the seeded summary out of a compacted conversation's first user turn.
- */
-function firstText(message: ChatMessage): string | undefined {
- for (const chunk of message.chunks) {
- if (chunk.type === "text") {
- const t = chunk.text.trim();
- if (t) return t;
- }
- }
- return undefined;
-}
-
-/**
- * Extract a prior summary from the conversation head. If the first user message
- * is a seeded summary (starts with {@link SUMMARY_MARKER}), return its body
- * (marker stripped) so the next compaction can anchor on it.
- */
-export function extractPreviousSummary(messages: ChatMessage[]): string | undefined {
- const first = messages.find((m) => m.role === "user");
- if (!first) return undefined;
- const text = firstText(first);
- if (!text?.startsWith(SUMMARY_MARKER)) return undefined;
- const body = text.slice(SUMMARY_MARKER.length).trim();
- return body || undefined;
-}
-
-export interface HeadTailSelection<T extends ChatMessage = ChatMessage> {
- /** Older messages to be summarized away. */
- head: T[];
- /** Recent messages preserved verbatim in the continuation. */
- tail: T[];
-}
-
-/**
- * Split a conversation into a summarizable `head` and a preserved `tail` of the
- * last `tailTurns` turns. A "turn" begins at a user message and runs until the
- * next user message.
- *
- * When the conversation has `tailTurns` or fewer turns, `head` is empty: there
- * is nothing to compact (the caller should refuse).
- */
-export function selectHeadTail<T extends ChatMessage>(
- messages: T[],
- tailTurns: number = DEFAULT_TAIL_TURNS,
-): HeadTailSelection<T> {
- if (tailTurns <= 0) return { head: messages, tail: [] };
- const userIndices: number[] = [];
- for (let i = 0; i < messages.length; i++) {
- if (messages[i]?.role === "user") userIndices.push(i);
- }
- if (userIndices.length <= tailTurns) return { head: [], tail: messages };
- const tailStart = userIndices[userIndices.length - tailTurns];
- if (tailStart === undefined || tailStart <= 0) return { head: [], tail: messages };
- return { head: messages.slice(0, tailStart), tail: messages.slice(tailStart) };
-}
-
-/** Cap a tool result to `max` chars with a truncation marker. */
-function capToolOutput(result: string, max: number): string {
- if (result.length <= max) return result;
- const omitted = result.length - max;
- return `${result.slice(0, max)}\n…[${omitted} chars truncated for summary]`;
-}
-
-/**
- * Render conversation messages into a compact, provider-agnostic plain-text
- * transcript suitable as summary-request context. Tool results are capped at
- * `toolOutputMaxChars` (opencode's `TOOL_OUTPUT_MAX_CHARS`), and a seeded prior
- * summary message is skipped (its content is carried by the prompt anchor).
- * Thinking/error/system chunks are omitted as summary noise.
- */
-export function renderTranscript(
- messages: ChatMessage[],
- toolOutputMaxChars: number = TOOL_OUTPUT_MAX_CHARS,
-): string {
- const blocks: string[] = [];
- for (const message of messages) {
- // Skip a seeded prior-summary user turn — it's represented via the anchor.
- if (message.role === "user") {
- const t = firstText(message);
- if (t?.startsWith(SUMMARY_MARKER)) continue;
- }
-
- const lines: string[] = [];
- for (const chunk of message.chunks) {
- if (chunk.type === "text") {
- const t = chunk.text.trim();
- if (t) lines.push(t);
- } else if (chunk.type === "tool-batch") {
- for (const call of chunk.calls) {
- let args = "";
- try {
- args = JSON.stringify(call.arguments ?? {});
- } catch {
- args = "{}";
- }
- lines.push(`[tool ${call.name} ${args}]`);
- if (call.result !== undefined) {
- const tag = call.isError ? "tool-error" : "tool-result";
- lines.push(`[${tag}] ${capToolOutput(call.result, toolOutputMaxChars)}`);
- }
- }
- }
- }
- if (lines.length === 0) continue;
- const role =
- message.role === "user" ? "User" : message.role === "assistant" ? "Assistant" : "System";
- blocks.push(`## ${role}\n${lines.join("\n")}`);
- }
- return blocks.join("\n\n");
-}
-
-export interface CompactionRequest<T extends ChatMessage = ChatMessage> {
- /** Messages selected for summarization (older head). */
- head: T[];
- /** Recent messages preserved verbatim (last N turns). */
- tail: T[];
- /** Prior summary anchored on, if the conversation was compacted before. */
- previousSummary?: string;
- /**
- * The full user-message content for the summary request: rendered head
- * transcript followed by the compaction prompt/template. `undefined` when
- * there is nothing to compact (`head` empty).
- */
- prompt?: string;
-}
-
-/**
- * Assemble everything needed to run a compaction: head/tail split, prior-summary
- * extraction, and the combined summary-request prompt. Returns `prompt:
- * undefined` when the conversation is too short to compact.
- */
-export function buildCompactionRequest<T extends ChatMessage>(input: {
- messages: T[];
- tailTurns?: number;
- toolOutputMaxChars?: number;
-}): CompactionRequest<T> {
- const tailTurns = input.tailTurns ?? DEFAULT_TAIL_TURNS;
- const toolMax = input.toolOutputMaxChars ?? TOOL_OUTPUT_MAX_CHARS;
- const { head, tail } = selectHeadTail(input.messages, tailTurns);
- const previousSummary = extractPreviousSummary(input.messages);
- if (head.length === 0) {
- return { head, tail, previousSummary };
- }
- const transcript = renderTranscript(head, toolMax);
- const instruction = buildCompactionPrompt({ previousSummary });
- const prompt = `${transcript}\n\n${instruction}`;
- return { head, tail, previousSummary, prompt };
-}
-
-/**
- * Wrap a generated summary as the seeded user-turn text for the continuation
- * conversation. Prefixed with {@link SUMMARY_MARKER} so a later compaction can
- * anchor on it.
- */
-export function buildSummaryTurnText(summary: string): string {
- return `${SUMMARY_MARKER}\n\n${summary.trim()}`;
-}
diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts
deleted file mode 100644
index 7f76dd7..0000000
--- a/packages/core/src/config/index.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export {
- configToRuleset,
- getGlobalConfigPath,
- loadConfig,
- loadGlobalConfig,
- mergeConfigs,
-} from "./loader.js";
-export { validateConfig } from "./schema.js";
-export { createConfigWatcher, watchDirConfig } from "./watcher.js";
diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts
deleted file mode 100644
index 66f798b..0000000
--- a/packages/core/src/config/loader.ts
+++ /dev/null
@@ -1,226 +0,0 @@
-import { readFileSync } from "node:fs";
-import { homedir } from "node:os";
-import { join } from "node:path";
-import { parse } from "smol-toml";
-import type { PermissionRule, Ruleset } from "../permission/index.js";
-import type { DispatchConfig, KeyDefinition, LspServerConfig } from "../types/index.js";
-import { validateConfig } from "./schema.js";
-
-const DEFAULT_CONFIG: DispatchConfig = { permissions: {} };
-
-const VALID_ACTIONS = new Set(["allow", "deny", "ask"]);
-
-function validateAction(raw: string): "allow" | "deny" | "ask" {
- if (VALID_ACTIONS.has(raw)) return raw as "allow" | "deny" | "ask";
- console.warn(`dispatch: unrecognized action "${raw}", defaulting to "ask"`);
- return "ask";
-}
-
-/**
- * Absolute path to the HOME-directory (global) `dispatch.toml`.
- *
- * Follows the same `~/.config/dispatch/` convention as global agents
- * (`~/.config/dispatch/agents`). This file is OPTIONAL; when present its
- * contents are merged underneath every project/working-directory config so
- * machine-wide settings (e.g. globally available LSP servers) work in any
- * repository without per-repo configuration.
- *
- * The path can be overridden with the `DISPATCH_GLOBAL_CONFIG` environment
- * variable, which is primarily useful for tests (point it at a temp file) but
- * also lets a user relocate the global config.
- */
-export function getGlobalConfigPath(): string {
- return (
- process.env.DISPATCH_GLOBAL_CONFIG ?? join(homedir(), ".config", "dispatch", "dispatch.toml")
- );
-}
-
-// Parse + validate a single dispatch.toml. Returns null when the file does not
-// exist. Re-throws TOML parse errors so a corrupt LOCAL config surfaces loudly
-// (callers that must stay resilient, e.g. the global loader, catch it).
-function readConfigFile(tomlPath: string): DispatchConfig | null {
- let raw: unknown;
- try {
- const content = readFileSync(tomlPath, "utf-8");
- raw = parse(content);
- } catch (err: unknown) {
- if (err instanceof Error && (err as NodeJS.ErrnoException).code === "ENOENT") {
- // File doesn't exist — signal "no config here".
- return null;
- }
- console.warn(
- `dispatch: failed to parse ${tomlPath}: ${err instanceof Error ? err.message : String(err)}`,
- );
- throw err;
- }
-
- const { config, errors } = validateConfig(raw);
- for (const e of errors) {
- console.warn(`dispatch: config warning at ${e.path}: ${e.message}`);
- }
- return config;
-}
-
-/**
- * Load the HOME-directory global `dispatch.toml` (see {@link getGlobalConfigPath}).
- *
- * Always resilient: a missing file yields the empty default and a malformed
- * file is logged but downgraded to the empty default rather than thrown. A
- * broken global config must never break config loading for every repository on
- * the machine.
- */
-export function loadGlobalConfig(): DispatchConfig {
- try {
- return readConfigFile(getGlobalConfigPath()) ?? DEFAULT_CONFIG;
- } catch (err) {
- console.warn(
- `dispatch: ignoring global config due to parse error: ${err instanceof Error ? err.message : String(err)}`,
- );
- return DEFAULT_CONFIG;
- }
-}
-
-/**
- * Load the effective config for `dir`: the global config MERGED with the
- * project/working-directory `dispatch.toml`, where the LOCAL config takes
- * precedence on conflicts (see {@link mergeConfigs}). A missing local file
- * yields the global config as-is; a missing global file yields the local
- * config as-is.
- *
- * Note: a malformed LOCAL config still throws (callers may surface it), while a
- * malformed GLOBAL config is downgraded to empty by {@link loadGlobalConfig}.
- */
-export function loadConfig(dir: string): DispatchConfig {
- const global = loadGlobalConfig();
- const local = readConfigFile(join(dir, "dispatch.toml"));
- if (local === null) return global;
- return mergeConfigs(global, local);
-}
-
-// ─── Merge ───────────────────────────────────────────────────────
-
-/**
- * Merge two permission blocks. Local takes precedence on conflicts.
- *
- * - A key present only in one side is carried over verbatim.
- * - A key whose value is a string on either side: local replaces global.
- * - A key that is a nested `{ pattern -> action }` object on BOTH sides is
- * merged pattern-by-pattern: global patterns the local block does NOT also
- * define come first (original order), then EVERY local pattern is appended
- * last (overriding any same-named global pattern).
- *
- * Emitting all local patterns after the global ones is essential, not
- * cosmetic: `configToRuleset` flattens patterns in iteration order and
- * `evaluate` uses `findLast` (last match wins). If an overridden pattern were
- * updated in place, a more-general global pattern (e.g. "*") could remain AFTER
- * it and silently shadow the local override. Appending local patterns last
- * reproduces a clean "global rules then local rules" concatenation so local
- * always wins.
- */
-function mergePermissions(
- global: DispatchConfig["permissions"],
- local: DispatchConfig["permissions"],
-): DispatchConfig["permissions"] {
- const result: DispatchConfig["permissions"] = {};
- for (const [key, value] of Object.entries(global)) {
- result[key] = value;
- }
- for (const [key, value] of Object.entries(local)) {
- const existing = result[key];
- if (existing !== undefined && typeof existing !== "string" && typeof value !== "string") {
- // Both nested objects — merge patterns so that ALL local patterns
- // are emitted AFTER the global ones. This matters because
- // `configToRuleset` flattens patterns in insertion order and
- // `evaluate` uses `findLast` (last match wins): a naive
- // `{ ...existing, ...value }` would update an overridden pattern
- // IN PLACE, leaving a more-general global pattern (e.g. "*") sitting
- // AFTER it and silently shadowing the local override. We therefore
- // drop any global pattern that the local block also defines, keep the
- // remaining global patterns in their original order, then append every
- // local pattern last — reproducing a clean "global rules then local
- // rules" concatenation where local always wins.
- const merged: Record<string, string> = {};
- for (const [pattern, action] of Object.entries(existing)) {
- if (!(pattern in value)) merged[pattern] = action;
- }
- for (const [pattern, action] of Object.entries(value)) {
- merged[pattern] = action;
- }
- result[key] = merged;
- } else {
- // Local string, brand-new key, or a string/object type mismatch:
- // local replaces global wholesale.
- result[key] = value;
- }
- }
- return result;
-}
-
-/**
- * Merge two key lists by `id`. Local keys override global keys sharing the same
- * id; non-conflicting ids from both lists survive. Global keys keep their
- * relative order (overridden in place) followed by local-only keys.
- */
-function mergeKeys(global: KeyDefinition[], local: KeyDefinition[]): KeyDefinition[] {
- const byId = new Map<string, KeyDefinition>();
- for (const key of global) byId.set(key.id, key);
- for (const key of local) byId.set(key.id, key);
- return Array.from(byId.values());
-}
-
-/**
- * Merge two `[lsp]` blocks by server id. Local servers override global servers
- * sharing the same id; non-conflicting ids from both sides remain active. This
- * is what lets a global config provide LSP servers to every repository while a
- * project can still override or add its own.
- */
-function mergeLsp(
- global: Record<string, LspServerConfig>,
- local: Record<string, LspServerConfig>,
-): Record<string, LspServerConfig> {
- return { ...global, ...local };
-}
-
-/**
- * Deep-merge a `global` config with a `local` (project/working-directory)
- * config, with LOCAL taking precedence on every conflict. Pure function — does
- * not touch the filesystem and never mutates its inputs.
- */
-export function mergeConfigs(global: DispatchConfig, local: DispatchConfig): DispatchConfig {
- const merged: DispatchConfig = {
- permissions: mergePermissions(global.permissions, local.permissions),
- };
-
- if (global.keys !== undefined || local.keys !== undefined) {
- merged.keys = mergeKeys(global.keys ?? [], local.keys ?? []);
- }
-
- if (global.lsp !== undefined || local.lsp !== undefined) {
- merged.lsp = mergeLsp(global.lsp ?? {}, local.lsp ?? {});
- }
-
- return merged;
-}
-
-// Convert the config's permission block to a Ruleset
-export function configToRuleset(config: DispatchConfig): Ruleset {
- const home = homedir();
- const rules: PermissionRule[] = [];
-
- for (const [permission, value] of Object.entries(config.permissions)) {
- if (typeof value === "string") {
- const action = validateAction(value);
- rules.push({ permission, pattern: "*", action });
- } else {
- for (const [rawPattern, rawAction] of Object.entries(value)) {
- const pattern = rawPattern
- .replace(/^\$HOME(?=[/\\]|$)/, home)
- .replace(/^~(?=[/\\]|$)/, home);
- const action = validateAction(rawAction);
- rules.push({ permission, pattern, action });
- }
- }
- }
-
- return rules;
-}
diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts
deleted file mode 100644
index 304ee10..0000000
--- a/packages/core/src/config/schema.ts
+++ /dev/null
@@ -1,239 +0,0 @@
-import type {
- ConfigError,
- DispatchConfig,
- KeyDefinition,
- LspServerConfig,
-} from "../types/index.js";
-
-function isRecord(value: unknown): value is Record<string, unknown> {
- return typeof value === "object" && value !== null && !Array.isArray(value);
-}
-
-function isStringRecord(value: unknown): value is Record<string, string> {
- if (!isRecord(value)) return false;
- return Object.values(value).every((v) => typeof v === "string");
-}
-
-function isValidAction(value: string): boolean {
- return value === "allow" || value === "deny" || value === "ask";
-}
-
-function isPermissionsValue(value: unknown): value is string | Record<string, string> {
- return typeof value === "string" || isStringRecord(value);
-}
-
-function validatePermissions(
- raw: unknown,
- path: string,
- errors: ConfigError[],
-): Record<string, string | Record<string, string>> {
- if (!isRecord(raw)) {
- errors.push({ path, message: "must be an object" });
- return {};
- }
- const result: Record<string, string | Record<string, string>> = {};
- for (const [key, value] of Object.entries(raw)) {
- if (!isPermissionsValue(value)) {
- errors.push({
- path: `${path}.${key}`,
- message: "must be a string or a flat string-keyed object",
- });
- continue;
- }
- if (typeof value === "string") {
- if (!isValidAction(value)) {
- errors.push({
- path: `${path}.${key}`,
- message: `invalid action "${value}"; must be "allow", "deny", or "ask"`,
- });
- continue;
- }
- } else {
- let hasError = false;
- for (const [pattern, action] of Object.entries(value)) {
- if (!isValidAction(action)) {
- errors.push({
- path: `${path}.${key}.${pattern}`,
- message: `invalid action "${action}"; must be "allow", "deny", or "ask"`,
- });
- hasError = true;
- }
- }
- if (hasError) continue;
- }
- result[key] = value;
- }
- return result;
-}
-
-function validateKey(raw: unknown, path: string, errors: ConfigError[]): KeyDefinition | null {
- if (!isRecord(raw)) {
- errors.push({ path, message: "must be an object" });
- return null;
- }
- if (typeof raw.id !== "string") {
- errors.push({ path: `${path}.id`, message: "must be a string" });
- return null;
- }
- if (typeof raw.provider !== "string") {
- errors.push({ path: `${path}.provider`, message: "must be a string" });
- return null;
- }
- if (typeof raw.base_url !== "string") {
- errors.push({ path: `${path}.base_url`, message: "must be a string" });
- return null;
- }
-
- // "anthropic" provider uses credentials_file instead of env
- if (raw.provider === "anthropic") {
- return {
- id: raw.id as string,
- provider: raw.provider as string,
- base_url: raw.base_url as string,
- ...(typeof raw.credentials_file === "string"
- ? ({ credentials_file: raw.credentials_file } as Pick<KeyDefinition, "credentials_file">)
- : {}),
- };
- }
-
- // Other providers: env is optional (keys can be stored in DB)
- return {
- id: raw.id as string,
- provider: raw.provider as string,
- base_url: raw.base_url as string,
- ...(typeof raw.env === "string" ? { env: raw.env } : {}),
- };
-}
-
-function isStringArray(value: unknown): value is string[] {
- return Array.isArray(value) && value.every((v) => typeof v === "string");
-}
-
-function validateLspServer(
- raw: unknown,
- path: string,
- errors: ConfigError[],
-): LspServerConfig | null {
- if (!isRecord(raw)) {
- errors.push({ path, message: "must be an object" });
- return null;
- }
-
- const disabled = raw.disabled === true;
-
- // `command` is required and must be a non-empty string array unless the
- // entry is explicitly disabled (a disabled entry is skipped wholesale).
- if (!disabled) {
- if (!isStringArray(raw.command) || raw.command.length === 0) {
- errors.push({
- path: `${path}.command`,
- message: "must be a non-empty array of strings",
- });
- return null;
- }
- // `extensions` is required for custom servers — without it the client
- // cannot know which files should activate the server.
- if (!isStringArray(raw.extensions) || raw.extensions.length === 0) {
- errors.push({
- path: `${path}.extensions`,
- message: 'must be a non-empty array of strings (e.g. [".luau"])',
- });
- return null;
- }
- } else {
- // Disabled entries still must not carry a malformed command/extensions
- // if present, but we do not require them.
- if (raw.command !== undefined && !isStringArray(raw.command)) {
- errors.push({ path: `${path}.command`, message: "must be an array of strings" });
- return null;
- }
- if (raw.extensions !== undefined && !isStringArray(raw.extensions)) {
- errors.push({ path: `${path}.extensions`, message: "must be an array of strings" });
- return null;
- }
- }
-
- if (raw.env !== undefined && !isStringRecord(raw.env)) {
- errors.push({
- path: `${path}.env`,
- message: "must be a flat string-keyed object",
- });
- return null;
- }
-
- if (raw.initialization !== undefined && !isRecord(raw.initialization)) {
- errors.push({
- path: `${path}.initialization`,
- message: "must be an object",
- });
- return null;
- }
-
- const server: LspServerConfig = {
- command: (raw.command as string[] | undefined) ?? [],
- extensions: (raw.extensions as string[] | undefined) ?? [],
- ...(isStringRecord(raw.env) ? { env: raw.env } : {}),
- ...(isRecord(raw.initialization)
- ? { initialization: raw.initialization as Record<string, unknown> }
- : {}),
- ...(disabled ? { disabled: true } : {}),
- };
- return server;
-}
-
-function validateLsp(
- raw: unknown,
- path: string,
- errors: ConfigError[],
-): Record<string, LspServerConfig> | undefined {
- if (!isRecord(raw)) {
- errors.push({ path, message: "must be an object" });
- return undefined;
- }
- const result: Record<string, LspServerConfig> = {};
- for (const [id, value] of Object.entries(raw)) {
- const server = validateLspServer(value, `${path}.${id}`, errors);
- if (server) result[id] = server;
- }
- return Object.keys(result).length > 0 ? result : undefined;
-}
-
-export function validateConfig(raw: unknown): { config: DispatchConfig; errors: ConfigError[] } {
- const errors: ConfigError[] = [];
-
- if (!isRecord(raw)) {
- errors.push({ path: "", message: "config must be an object" });
- return { config: { permissions: {} }, errors };
- }
-
- // permissions (required, but can be empty)
- const permissions = validatePermissions(raw.permissions ?? {}, "permissions", errors);
-
- // keys (optional)
- let keys: KeyDefinition[] | undefined;
- if (raw.keys !== undefined) {
- if (!Array.isArray(raw.keys)) {
- errors.push({ path: "keys", message: "must be an array" });
- } else {
- keys = [];
- for (let i = 0; i < raw.keys.length; i++) {
- const key = validateKey(raw.keys[i], `keys[${i}]`, errors);
- if (key) keys.push(key);
- }
- }
- }
-
- // lsp (optional)
- let lsp: Record<string, LspServerConfig> | undefined;
- if (raw.lsp !== undefined) {
- lsp = validateLsp(raw.lsp, "lsp", errors);
- }
-
- const config: DispatchConfig = {
- permissions,
- ...(keys !== undefined && { keys }),
- ...(lsp !== undefined && { lsp }),
- };
-
- return { config, errors };
-}
diff --git a/packages/core/src/config/watcher.ts b/packages/core/src/config/watcher.ts
deleted file mode 100644
index ad55804..0000000
--- a/packages/core/src/config/watcher.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-import { join } from "node:path";
-import { watch } from "chokidar";
-import type { DispatchConfig } from "../types/index.js";
-import { getGlobalConfigPath, loadConfig } from "./loader.js";
-
-/**
- * Watch BOTH the HOME-directory global `dispatch.toml` and the project/working-
- * directory `dispatch.toml`. Either file changing triggers a reload that
- * re-merges global + local (via {@link loadConfig}), so hot-reload works for
- * global defaults and per-project overrides alike.
- *
- * When the global and local paths coincide (e.g. the working directory IS
- * `~/.config/dispatch`, or `DISPATCH_GLOBAL_CONFIG` points at the local file)
- * the duplicate is collapsed so chokidar only watches it once.
- */
-export function createConfigWatcher(
- dir: string,
- onChange: (config: DispatchConfig) => void,
-): { close(): void } {
- const localPath = join(dir, "dispatch.toml");
- const globalPath = getGlobalConfigPath();
- const paths = globalPath === localPath ? [localPath] : [globalPath, localPath];
- let debounceTimer: ReturnType<typeof setTimeout> | null = null;
-
- const watcher = watch(paths, {
- ignoreInitial: true,
- persistent: false,
- });
-
- const handleChange = () => {
- if (debounceTimer !== null) {
- clearTimeout(debounceTimer);
- }
- debounceTimer = setTimeout(() => {
- debounceTimer = null;
- console.log(`dispatch: reloading config (global + ${localPath})`);
- try {
- const config = loadConfig(dir);
- onChange(config);
- } catch (err) {
- console.warn(
- `dispatch: retaining last known config due to parse error: ${err instanceof Error ? err.message : String(err)}`,
- );
- }
- }, 300);
- };
-
- watcher.on("change", handleChange);
- watcher.on("add", handleChange);
- watcher.on("unlink", handleChange);
-
- watcher.on("error", (err) => {
- console.warn(
- `dispatch: config watcher error: ${err instanceof Error ? err.message : String(err)}`,
- );
- });
-
- return {
- close() {
- if (debounceTimer !== null) {
- clearTimeout(debounceTimer);
- debounceTimer = null;
- }
- watcher.close().catch((err) => {
- console.warn(
- `dispatch: error closing config watcher: ${err instanceof Error ? err.message : String(err)}`,
- );
- });
- },
- };
-}
-
-/**
- * Watch a SINGLE directory's `dispatch.toml` (no global merge, no reload — just
- * a debounced change signal). Used by the agent manager to invalidate its
- * per-directory LSP cache when a tab's effective working directory is a
- * SUBDIRECTORY with its own `dispatch.toml`: the main `createConfigWatcher`
- * only watches the root + global configs, so without this a nested config edit
- * would never clear `lspServersByDir[subdir]` and agents there would keep using
- * stale LSP servers until a root-config change or restart.
- *
- * `onChange` fires (debounced) on add/change/unlink of `<dir>/dispatch.toml`.
- */
-export function watchDirConfig(dir: string, onChange: () => void): { close(): void } {
- const tomlPath = join(dir, "dispatch.toml");
- let debounceTimer: ReturnType<typeof setTimeout> | null = null;
-
- const watcher = watch(tomlPath, {
- ignoreInitial: true,
- persistent: false,
- });
-
- const handleChange = () => {
- if (debounceTimer !== null) clearTimeout(debounceTimer);
- debounceTimer = setTimeout(() => {
- debounceTimer = null;
- try {
- onChange();
- } catch (err) {
- console.warn(
- `dispatch: dir config watcher onChange error: ${err instanceof Error ? err.message : String(err)}`,
- );
- }
- }, 300);
- };
-
- watcher.on("change", handleChange);
- watcher.on("add", handleChange);
- watcher.on("unlink", handleChange);
- watcher.on("error", (err) => {
- console.warn(
- `dispatch: dir config watcher error: ${err instanceof Error ? err.message : String(err)}`,
- );
- });
-
- return {
- close() {
- if (debounceTimer !== null) {
- clearTimeout(debounceTimer);
- debounceTimer = null;
- }
- watcher.close().catch((err) => {
- console.warn(
- `dispatch: error closing dir config watcher: ${err instanceof Error ? err.message : String(err)}`,
- );
- });
- },
- };
-}
diff --git a/packages/core/src/credentials/anthropic-betas.ts b/packages/core/src/credentials/anthropic-betas.ts
deleted file mode 100644
index 802ebbd..0000000
--- a/packages/core/src/credentials/anthropic-betas.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-// ─── Anthropic Beta Headers ───────────────────────────────────
-//
-// The set of `anthropic-beta` features the official Claude Code CLI sends on
-// every request. Kept in a dependency-free module (no DB / `bun:sqlite`
-// import) so the LLM provider layer (`llm/provider.ts`) can pull the list in
-// without dragging the whole credentials/DB stack into its import graph.
-//
-// `prompt-caching-scope-2026-01-05` is load-bearing for cost: without it the
-// Anthropic API silently ignores every `cache_control` breakpoint we place,
-// producing a 0% cache hit rate (see notes/claude-report.md). `oauth-2025-04-20`
-// gates the Bearer/OAuth flow used by Claude Pro/Max subscriptions.
-
-const BASE_BETAS = [
- "claude-code-20250219",
- "oauth-2025-04-20",
- "interleaved-thinking-2025-05-14",
- "prompt-caching-scope-2026-01-05",
- "context-management-2025-06-27",
- "advisor-tool-2026-03-01",
-];
-
-export function getAnthropicBetas(): string[] {
- return [...BASE_BETAS];
-}
diff --git a/packages/core/src/credentials/api-keys.ts b/packages/core/src/credentials/api-keys.ts
deleted file mode 100644
index ef30400..0000000
--- a/packages/core/src/credentials/api-keys.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-import { getDatabase } from "../db/index.js";
-
-export interface StoredApiKey {
- keyId: string;
- provider: string;
- apiKey: string;
- importedAt: number;
- updatedAt: number;
-}
-
-/**
- * Store or update an API key in the database.
- */
-export function setApiKey(keyId: string, provider: string, apiKey: string): void {
- const db = getDatabase();
- const now = Date.now();
- db.query(
- `INSERT INTO api_keys (key_id, provider, api_key, imported_at, updated_at)
- VALUES ($keyId, $provider, $apiKey, $now, $now)
- ON CONFLICT(key_id) DO UPDATE SET
- api_key = $apiKey,
- updated_at = $now`,
- ).run({
- $keyId: keyId,
- $provider: provider,
- $apiKey: apiKey,
- $now: now,
- });
-}
-
-/**
- * Get a stored API key by key ID. Returns the key string or null.
- */
-export function getApiKey(keyId: string): string | null {
- const db = getDatabase();
- const row = db
- .query("SELECT api_key FROM api_keys WHERE key_id = $keyId")
- .get({ $keyId: keyId }) as { api_key: string } | null;
- return row?.api_key ?? null;
-}
-
-/**
- * Resolve an API key from the database, with env var fallback.
- * Pass the env var name (e.g. "GOOGLE_API_KEY") to check process.env as well.
- */
-export function resolveApiKey(keyId: string, envVar?: string): string | null {
- const dbKey = getApiKey(keyId);
- if (dbKey) return dbKey;
- if (envVar) return process.env[envVar] ?? null;
- return null;
-}
-
-/**
- * Delete a stored API key.
- */
-export function deleteApiKey(keyId: string): void {
- const db = getDatabase();
- db.query("DELETE FROM api_keys WHERE key_id = $keyId").run({ $keyId: keyId });
-}
-
-/**
- * List all stored API keys with metadata (key value excluded for security).
- */
-export function listApiKeys(): Array<{
- keyId: string;
- provider: string;
- importedAt: number;
- updatedAt: number;
-}> {
- const db = getDatabase();
- const rows = db
- .query("SELECT key_id, provider, imported_at, updated_at FROM api_keys ORDER BY key_id")
- .all() as Array<Record<string, unknown>>;
- return rows.map((row) => ({
- keyId: row.key_id as string,
- provider: row.provider as string,
- importedAt: row.imported_at as number,
- updatedAt: row.updated_at as number,
- }));
-}
diff --git a/packages/core/src/credentials/claude.ts b/packages/core/src/credentials/claude.ts
deleted file mode 100644
index 050a0fc..0000000
--- a/packages/core/src/credentials/claude.ts
+++ /dev/null
@@ -1,694 +0,0 @@
-import { createHash } from "node:crypto";
-import {
- chmodSync,
- existsSync,
- mkdirSync,
- readdirSync,
- readFileSync,
- writeFileSync,
-} from "node:fs";
-import { homedir } from "node:os";
-import { basename, dirname, join } from "node:path";
-import { getDatabase } from "../db/index.js";
-import { getAnthropicBetas } from "./anthropic-betas.js";
-import { getStoredCredentials, listStoredCredentials, updateStoredTokens } from "./store.js";
-
-// Re-exported for backward compatibility — `getAnthropicBetas` historically
-// lived here and is surfaced through `credentials/index.ts`. The definition
-// now lives in the dependency-free `anthropic-betas.ts` module.
-export { getAnthropicBetas };
-
-export interface ClaudeCredentials {
- accessToken: string;
- refreshToken: string;
- expiresAt: number;
- subscriptionType?: string;
-}
-
-export interface ClaudeAccount {
- id: string;
- label: string;
- source: string;
- credentials: ClaudeCredentials;
-}
-
-const OAUTH_TOKEN_URL = "https://claude.ai/v1/oauth/token";
-const OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
-const CREDENTIAL_CACHE_TTL_MS = 30_000;
-
-const CREDENTIALS_DIR = join(homedir(), ".claude");
-const PRIMARY_CREDENTIALS_FILE = join(CREDENTIALS_DIR, ".credentials.json");
-
-const accountCacheMap = new Map<string, { creds: ClaudeCredentials; cachedAt: number }>();
-
-function parseCredentialsFile(raw: string): ClaudeCredentials | null {
- let parsed: unknown;
- try {
- parsed = JSON.parse(raw);
- } catch {
- return null;
- }
-
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
-
- const data = (parsed as Record<string, unknown>).claudeAiOauth ?? parsed;
- const creds = data as Record<string, unknown>;
-
- if (
- (creds as Record<string, unknown>).mcpOAuth &&
- !(creds as Record<string, unknown>).accessToken
- ) {
- return null;
- }
-
- if (
- typeof creds.accessToken !== "string" ||
- typeof creds.refreshToken !== "string" ||
- typeof creds.expiresAt !== "number"
- ) {
- return null;
- }
-
- return {
- accessToken: creds.accessToken as string,
- refreshToken: creds.refreshToken as string,
- expiresAt: creds.expiresAt as number,
- subscriptionType:
- typeof creds.subscriptionType === "string" ? creds.subscriptionType : undefined,
- };
-}
-
-function readCredentialsFile(filePath: string): ClaudeCredentials | null {
- try {
- if (!existsSync(filePath)) return null;
- const raw = readFileSync(filePath, "utf-8").trim();
- if (!raw) return null;
- return parseCredentialsFile(raw);
- } catch {
- return null;
- }
-}
-
-function writeCredentialsFile(filePath: string, creds: ClaudeCredentials): void {
- let existing: Record<string, unknown> = {};
- try {
- if (existsSync(filePath)) {
- const raw = readFileSync(filePath, "utf-8").trim();
- if (raw) {
- existing = JSON.parse(raw);
- }
- }
- } catch {
- existing = {};
- }
-
- const hasWrapper = "claudeAiOauth" in existing;
- const target = hasWrapper ? (existing.claudeAiOauth as Record<string, unknown>) : existing;
- target.accessToken = creds.accessToken;
- target.refreshToken = creds.refreshToken;
- target.expiresAt = creds.expiresAt;
- if (creds.subscriptionType) {
- target.subscriptionType = creds.subscriptionType;
- }
-
- const dir = dirname(filePath);
- if (!existsSync(dir)) {
- mkdirSync(dir, { recursive: true, mode: 0o700 });
- }
- writeFileSync(filePath, JSON.stringify(existing, null, 2), { encoding: "utf-8", mode: 0o600 });
- if (process.platform !== "win32") {
- chmodSync(filePath, 0o600);
- }
-}
-
-async function refreshViaOAuth(refreshToken: string): Promise<ClaudeCredentials | null> {
- const body = new URLSearchParams({
- grant_type: "refresh_token",
- client_id: OAUTH_CLIENT_ID,
- refresh_token: refreshToken,
- });
-
- try {
- const response = await fetch(OAUTH_TOKEN_URL, {
- method: "POST",
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
- body: body.toString(),
- });
-
- if (!response.ok) {
- return null;
- }
-
- const data = (await response.json()) as Record<string, unknown>;
- if (!data.access_token || typeof data.access_token !== "string") {
- return null;
- }
-
- return {
- accessToken: data.access_token as string,
- refreshToken: (data.refresh_token as string) ?? refreshToken,
- expiresAt: Date.now() + ((data.expires_in as number) ?? 36_000) * 1000,
- subscriptionType:
- typeof data.subscriptionType === "string" ? data.subscriptionType : undefined,
- };
- } catch {
- return null;
- }
-}
-
-function buildAccountLabels(accounts: ClaudeAccount[]): void {
- for (const acct of accounts) {
- acct.label = acct.credentials.subscriptionType
- ? `Claude ${acct.credentials.subscriptionType.charAt(0).toUpperCase() + acct.credentials.subscriptionType.slice(1)}`
- : "Claude";
- }
-}
-
-/**
- * Load Claude accounts from the SQLite database.
- * Returns accounts for all stored anthropic credentials.
- * This is the preferred path — file-based discovery is the fallback.
- */
-export function getClaudeAccountsFromDB(): ClaudeAccount[] {
- const stored = listStoredCredentials();
- const accounts: ClaudeAccount[] = [];
-
- for (const cred of stored) {
- if (cred.provider !== "anthropic") continue;
- accounts.push({
- id: cred.keyId,
- label: "",
- source: `db:${cred.keyId}`,
- credentials: {
- accessToken: cred.accessToken,
- refreshToken: cred.refreshToken,
- expiresAt: cred.expiresAt,
- subscriptionType: cred.subscriptionType ?? undefined,
- },
- });
- }
-
- buildAccountLabels(accounts);
- return accounts;
-}
-
-export function discoverClaudeAccounts(): ClaudeAccount[] {
- const accounts: ClaudeAccount[] = [];
-
- if (!existsSync(CREDENTIALS_DIR)) {
- return accounts;
- }
-
- const primaryCreds = readCredentialsFile(PRIMARY_CREDENTIALS_FILE);
- if (primaryCreds) {
- accounts.push({
- id: "claude-default",
- label: "",
- source: PRIMARY_CREDENTIALS_FILE,
- credentials: primaryCreds,
- });
- }
-
- try {
- const files = readdirSync(CREDENTIALS_DIR);
- const credFiles = files.filter(
- (f) => f.startsWith(".credentials") && f.endsWith(".json") && f !== ".credentials.json",
- );
- for (const file of credFiles) {
- const filePath = join(CREDENTIALS_DIR, file);
- const creds = readCredentialsFile(filePath);
- if (creds) {
- const id = basename(file, ".json").replace(/^\.credentials/, "claude") || `claude-${file}`;
- accounts.push({
- id,
- label: "",
- source: filePath,
- credentials: creds,
- });
- }
- }
- } catch {
- // ignore
- }
-
- buildAccountLabels(accounts);
- return accounts;
-}
-
-export function refreshAccountCredentials(account: ClaudeAccount): ClaudeCredentials | null {
- const cached = accountCacheMap.get(account.id);
- const now = Date.now();
- if (
- cached &&
- now - cached.cachedAt < CREDENTIAL_CACHE_TTL_MS &&
- cached.creds.expiresAt > now + 60_000
- ) {
- return cached.creds;
- }
-
- // Re-read credentials: from DB for DB-backed accounts, from file otherwise
- if (account.source.startsWith("db:")) {
- const stored = getStoredCredentials(account.id);
- if (stored) {
- account.credentials = {
- accessToken: stored.accessToken,
- refreshToken: stored.refreshToken,
- expiresAt: stored.expiresAt,
- subscriptionType: stored.subscriptionType ?? undefined,
- };
- }
- } else {
- const onDisk = readCredentialsFile(account.source);
- if (onDisk) {
- account.credentials = onDisk;
- }
- }
-
- if (account.credentials.expiresAt > now + 60_000) {
- accountCacheMap.set(account.id, { creds: account.credentials, cachedAt: now });
- return account.credentials;
- }
-
- // Try OAuth refresh
- if (account.credentials.refreshToken) {
- // Synchronous refresh not available in this context, but the async version will be used
- // by getCredentialsForAccount below
- return null;
- }
-
- return null;
-}
-
-export async function refreshAccountCredentialsAsync(
- account: ClaudeAccount,
-): Promise<ClaudeCredentials | null> {
- const cached = accountCacheMap.get(account.id);
- const now = Date.now();
- if (
- cached &&
- now - cached.cachedAt < CREDENTIAL_CACHE_TTL_MS &&
- cached.creds.expiresAt > now + 60_000
- ) {
- return cached.creds;
- }
-
- // Re-read credentials: from DB for DB-backed accounts, from file otherwise
- if (account.source.startsWith("db:")) {
- const stored = getStoredCredentials(account.id);
- if (stored) {
- account.credentials = {
- accessToken: stored.accessToken,
- refreshToken: stored.refreshToken,
- expiresAt: stored.expiresAt,
- subscriptionType: stored.subscriptionType ?? undefined,
- };
- }
- } else {
- const onDisk = readCredentialsFile(account.source);
- if (onDisk) {
- account.credentials = onDisk;
- }
- }
-
- if (account.credentials.expiresAt > now + 60_000) {
- accountCacheMap.set(account.id, { creds: account.credentials, cachedAt: now });
- return account.credentials;
- }
-
- // Try OAuth refresh
- if (account.credentials.refreshToken) {
- const refreshed = await refreshViaOAuth(account.credentials.refreshToken);
- if (refreshed && refreshed.expiresAt > now + 60_000) {
- account.credentials = refreshed;
- // Update DB if this is a DB-backed account, otherwise write to file
- if (account.source.startsWith("db:")) {
- updateStoredTokens(
- account.id,
- refreshed.accessToken,
- refreshed.refreshToken,
- refreshed.expiresAt,
- );
- } else {
- writeCredentialsFile(account.source, refreshed);
- }
- accountCacheMap.set(account.id, { creds: refreshed, cachedAt: now });
- return refreshed;
- }
- }
-
- return null;
-}
-
-// ─── Billing Header Computation ────────────────────────────────
-
-const BILLING_SALT = "59cf53e54c78";
-const CC_VERSION = "2.1.112";
-
-function extractFirstUserMessageText(messages: Array<{ role: string; content: string }>): string {
- const userMsg = messages.find((m) => m.role === "user");
- if (!userMsg) return "";
- if (typeof userMsg.content === "string") return userMsg.content;
- return "";
-}
-
-function computeCch(messageText: string): string {
- return createHash("sha256").update(messageText).digest("hex").slice(0, 5);
-}
-
-function computeVersionSuffix(messageText: string, version: string): string {
- const sampled = [4, 7, 20].map((i) => (i < messageText.length ? messageText[i] : "0")).join("");
- const input = `${BILLING_SALT}${sampled}${version}`;
- return createHash("sha256").update(input).digest("hex").slice(0, 3);
-}
-
-export function buildBillingHeaderValue(
- messages: Array<{ role: string; content: string }>,
-): string {
- const text = extractFirstUserMessageText(messages);
- const version = process.env.ANTHROPIC_CLI_VERSION ?? CC_VERSION;
- const suffix = computeVersionSuffix(text, version);
- const cch = computeCch(text);
- return `x-anthropic-billing-header: cc_version=${version}.${suffix}; cc_entrypoint=sdk-cli; cch=${cch};`;
-}
-
-export const SYSTEM_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
-
-/**
- * Build the request body for a Claude "wake" probe — a tiny, cheap message
- * whose only purpose is to keep the subscription's rate-limit window warm.
- *
- * This MUST mirror the shape of a genuine Claude Code CLI request, because
- * Anthropic validates the `system[]` array on OAuth (Pro/Max) -authenticated,
- * Claude-Code-billed requests. A bare `{ model, messages }` body (no system
- * identity) is rejected (401/403) — which is exactly how the old probe silently
- * failed. The valid shape is:
- *
- * system: [
- * { type: "text", text: "x-anthropic-billing-header: ..." }, // billing, no cache_control
- * { type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." },
- * ]
- * messages: [ { role: "user", content: "hi" } ]
- *
- * Mirrors the runtime `transformClaudeOAuthBody` output for a single short user
- * turn. Pure: deterministic given its inputs (the billing header samples only
- * the user text), so it can be unit-tested without touching the network.
- */
-export function buildWakeProbeBody(model: string): {
- model: string;
- max_tokens: number;
- system: Array<{ type: "text"; text: string }>;
- messages: Array<{ role: "user"; content: string }>;
-} {
- const messages = [{ role: "user" as const, content: "hi" }];
- return {
- model,
- max_tokens: 16,
- system: [
- { type: "text", text: buildBillingHeaderValue(messages) },
- { type: "text", text: SYSTEM_IDENTITY },
- ],
- messages,
- };
-}
-
-// ─── Anthropic Request Headers ────────────────────────────────
-
-export function getAnthropicHeaders(accessToken: string): Record<string, string> {
- return {
- authorization: `Bearer ${accessToken}`,
- "anthropic-version": "2023-06-01",
- "anthropic-beta": getAnthropicBetas().join(","),
- "anthropic-dangerous-direct-browser-access": "true",
- "x-app": "cli",
- "user-agent": `claude-cli/${CC_VERSION} (external, sdk-cli)`,
- };
-}
-
-// ─── Usage Tracking ───────────────────────────────────────────
-
-export interface ClaudeUsageBucket {
- utilization?: number;
- resetsAt?: number;
-}
-
-export interface ClaudeUsageReport {
- fiveHour?: ClaudeUsageBucket;
- sevenDay?: ClaudeUsageBucket;
- sevenDayOpus?: ClaudeUsageBucket;
- sevenDaySonnet?: ClaudeUsageBucket;
- accountId?: string;
- email?: string;
- orgId?: string;
-}
-
-/**
- * A usage report paired with provenance: whether it came back from a fresh
- * live fetch against Anthropic's `/api/oauth/usage` endpoint or was served
- * from the local `usage_cache` table after a failed/skipped live fetch.
- *
- * `source: "cache"` carries `cachedAt` — the epoch-ms timestamp recording when
- * that cached payload was last fetched FROM the source (the `usage_cache.cached_at`
- * column). `source: "live"` omits `cachedAt` (the data is current as of now).
- */
-export interface ClaudeUsageResult {
- report: ClaudeUsageReport;
- source: "live" | "cache";
- /** Epoch-ms the cached report was last fetched from source. Only on `source: "cache"`. */
- cachedAt?: number;
-}
-
-// ─── Well-known Anthropic models ──────────────────────────────
-
-/**
- * Fetch the live list of available models from Anthropic's /v1/models endpoint.
- * Requires valid OAuth credentials with anthropic-beta headers.
- */
-export async function fetchAnthropicModels(accessToken: string): Promise<string[]> {
- const headers: Record<string, string> = {
- ...getAnthropicHeaders(accessToken),
- accept: "application/json",
- };
-
- try {
- const response = await fetch("https://api.anthropic.com/v1/models", { headers });
- if (!response.ok) {
- console.warn(`dispatch: Anthropic /v1/models returned ${response.status}`);
- return [];
- }
-
- const data = (await response.json()) as {
- data?: Array<{ id: string }>;
- models?: Array<{ id: string }>;
- };
- const entries = data.data ?? data.models ?? [];
- return entries.map((m) => m.id).filter(Boolean);
- } catch (err) {
- console.warn(
- `dispatch: failed to fetch Anthropic models: ${err instanceof Error ? err.message : String(err)}`,
- );
- return [];
- }
-}
-
-/** Fallback list if /v1/models is unreachable. */
-export const ANTHROPIC_MODELS_FALLBACK = [
- "claude-sonnet-4-20250514",
- "claude-opus-4-20250514",
- "claude-3.5-sonnet-20241022",
- "claude-3.5-haiku-20241022",
- "claude-3-opus-20240229",
-];
-
-/**
- * Pick the model to use for a Claude "wake" probe from a list of model ids.
- *
- * The probe only needs a small/cheap model to register activity against the
- * subscription, so we target Haiku. Model ids change over time (the old
- * hardcoded `claude-3-5-haiku-20241022` started returning HTTP 404), so the
- * caller fetches the live list from `/v1/models` and we resolve by substring.
- *
- * Selection: the FIRST id whose name contains "haiku" (case-insensitive).
- * Anthropic's `/v1/models` returns models newest-first, so first-match
- * naturally prefers the newest Haiku. Returns `null` when nothing matches so
- * the caller can surface a clear error instead of probing an invalid model.
- */
-export function selectHaikuModel(models: string[]): string | null {
- return models.find((id) => id.toLowerCase().includes("haiku")) ?? null;
-}
-
-// ─── Credential Validation ────────────────────────────────────
-
-export interface ClaudeProfile {
- accountId?: string;
- email?: string;
- subscriptionType?: string;
-}
-
-/**
- * Validate that Claude credentials are usable by hitting the OAuth profile endpoint.
- * Returns the profile info if valid, or null if the token is dead.
- */
-export async function validateAccountCredentials(
- account: ClaudeAccount,
-): Promise<ClaudeProfile | null> {
- const creds = await refreshAccountCredentialsAsync(account);
- if (!creds) return null;
-
- const url = "https://api.anthropic.com/api/oauth/profile";
- const headers: Record<string, string> = {
- ...getAnthropicHeaders(creds.accessToken),
- accept: "application/json, text/plain, */*",
- };
-
- try {
- const response = await fetch(url, { headers });
- if (!response.ok) return null;
-
- const data = (await response.json()) as Record<string, unknown>;
-
- const profile: ClaudeProfile = {};
- const uuid = typeof data.uuid === "string" ? data.uuid : undefined;
- const email = typeof data.email === "string" ? data.email : undefined;
-
- if (uuid) profile.accountId = uuid;
- if (email) profile.email = email;
-
- // subscriptionType comes from the credentials file, but profile may also carry it
- profile.subscriptionType = account.credentials.subscriptionType;
-
- return profile;
- } catch {
- return null;
- }
-}
-
-async function fetchClaudeUsage(accessToken: string): Promise<ClaudeUsageReport | null> {
- const url = "https://api.anthropic.com/api/oauth/usage";
- const headers: Record<string, string> = {
- ...getAnthropicHeaders(accessToken),
- accept: "application/json, text/plain, */*",
- "content-type": "application/json",
- };
-
- try {
- const response = await fetch(url, { headers });
- if (!response.ok) return null;
-
- const orgId = response.headers.get("anthropic-organization-id")?.trim() || undefined;
- const data = (await response.json()) as Record<string, unknown>;
-
- const parseBucket = (bucket: unknown): ClaudeUsageBucket | undefined => {
- if (!bucket || typeof bucket !== "object" || Array.isArray(bucket)) return undefined;
- const b = bucket as Record<string, unknown>;
- // API returns utilization as 0-100 percentage; normalize to 0-1 fraction
- const rawUtil = typeof b.utilization === "number" ? b.utilization : undefined;
- const utilization = rawUtil !== undefined ? rawUtil / 100 : undefined;
- const resetsAt =
- typeof b.resets_at === "string" ? Date.parse(b.resets_at as string) : undefined;
- if (utilization === undefined && resetsAt === undefined) return undefined;
- return { utilization, resetsAt };
- };
-
- const report: ClaudeUsageReport = {
- fiveHour: parseBucket(data.five_hour),
- sevenDay: parseBucket(data.seven_day),
- sevenDayOpus: parseBucket(data.seven_day_opus),
- sevenDaySonnet: parseBucket(data.seven_day_sonnet),
- };
-
- if (orgId) report.orgId = orgId;
-
- // Try to extract identity
- const accountId =
- typeof data.account_id === "string"
- ? data.account_id
- : typeof data.user_id === "string"
- ? data.user_id
- : typeof data.org_id === "string"
- ? data.org_id
- : undefined;
- if (accountId) report.accountId = accountId;
-
- const email = typeof data.email === "string" ? data.email : undefined;
- if (email) report.email = email;
-
- return report;
- } catch {
- return null;
- }
-}
-
-/**
- * Read a cached usage report plus the epoch-ms it was last fetched from source.
- * Returns `null` when there is no cached row (or on any DB/parse error).
- */
-function getCachedUsageWithMeta(
- keyId: string,
-): { report: ClaudeUsageReport; cachedAt: number } | null {
- try {
- const db = getDatabase();
- const row = db
- .query("SELECT report_json, cached_at FROM usage_cache WHERE key_id = $keyId")
- .get({ $keyId: keyId }) as { report_json: string; cached_at: number } | null;
- if (!row) return null;
- return {
- report: JSON.parse(row.report_json) as ClaudeUsageReport,
- cachedAt: row.cached_at,
- };
- } catch {
- return null;
- }
-}
-
-function setCachedUsage(keyId: string, provider: string, report: ClaudeUsageReport): void {
- try {
- const db = getDatabase();
- db.query(
- `INSERT INTO usage_cache (key_id, provider, cached_at, report_json)
- VALUES ($keyId, $provider, $cachedAt, $reportJson)
- ON CONFLICT(key_id) DO UPDATE SET
- cached_at = $cachedAt,
- report_json = $reportJson`,
- ).run({
- $keyId: keyId,
- $provider: provider,
- $cachedAt: Date.now(),
- $reportJson: JSON.stringify(report),
- });
- } catch {
- // Ignore DB errors
- }
-}
-
-/**
- * Fetch an account's usage report along with its provenance (live vs cache).
- *
- * Resolution: refresh credentials and hit the live `/api/oauth/usage` endpoint;
- * on success the fresh report is cached and returned as `source: "live"`. If
- * credentials cannot be refreshed OR the live fetch returns nothing, fall back
- * to the local `usage_cache` row and return it as `source: "cache"` with the
- * `cachedAt` timestamp recording when that payload was last fetched from source.
- * Returns `null` only when neither a live report nor a cached row is available.
- */
-export async function getAccountUsageWithSource(
- account: ClaudeAccount,
-): Promise<ClaudeUsageResult | null> {
- const creds = await refreshAccountCredentialsAsync(account);
- if (creds) {
- const report = await fetchClaudeUsage(creds.accessToken);
- if (report) {
- setCachedUsage(account.id, "anthropic", report);
- return { report, source: "live" };
- }
- }
- const cached = getCachedUsageWithMeta(account.id);
- if (cached) {
- return { report: cached.report, source: "cache", cachedAt: cached.cachedAt };
- }
- return null;
-}
-
-export async function getAccountUsage(account: ClaudeAccount): Promise<ClaudeUsageReport | null> {
- const result = await getAccountUsageWithSource(account);
- return result?.report ?? null;
-}
diff --git a/packages/core/src/credentials/copilot.ts b/packages/core/src/credentials/copilot.ts
deleted file mode 100644
index 8baf6f4..0000000
--- a/packages/core/src/credentials/copilot.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-// ─── GitHub Copilot Usage Tracking ───────────────────────────
-// Uses the internal GitHub Copilot user endpoint (same one the
-// official VS Code Copilot extension calls).
-
-export interface CopilotUsageReport {
- tokensConsumed?: number;
- tokensRemaining?: number;
- percentUsed?: number; // 0-100
- resetAt?: number; // Unix timestamp ms
- plan?: string;
-}
-
-export async function fetchCopilotUsage(
- token: string,
- _baseUrl: string,
-): Promise<CopilotUsageReport | null> {
- const url = "https://api.github.com/copilot_internal/user";
- const headers: Record<string, string> = {
- authorization: `Bearer ${token}`,
- accept: "application/json",
- };
-
- try {
- const response = await fetch(url, { headers });
- if (!response.ok) return null;
-
- const data = (await response.json()) as Record<string, unknown>;
- const plan = typeof data.copilot_plan === "string" ? data.copilot_plan : undefined;
-
- const resetDate = typeof data.quota_reset_date === "string" ? data.quota_reset_date : undefined;
- const resetAt = resetDate ? Date.parse(resetDate) : undefined;
-
- const qs = data.quota_snapshots as Record<string, unknown> | undefined;
- const pi = qs?.premium_interactions as Record<string, unknown> | undefined;
-
- const entitlement = typeof pi?.entitlement === "number" ? pi.entitlement : undefined;
- const remaining = typeof pi?.remaining === "number" ? pi.remaining : undefined;
- const percentRemaining =
- typeof pi?.percent_remaining === "number" ? pi.percent_remaining : undefined;
-
- if (entitlement === undefined && remaining === undefined) {
- return null;
- }
-
- const tokensConsumed =
- entitlement !== undefined && remaining !== undefined ? entitlement - remaining : undefined;
- const percentUsed =
- percentRemaining !== undefined
- ? Math.round((100 - percentRemaining) * 100) / 100
- : tokensConsumed !== undefined && entitlement !== undefined && entitlement > 0
- ? Math.round((tokensConsumed / entitlement) * 10000) / 100
- : undefined;
-
- return {
- tokensConsumed,
- tokensRemaining: remaining,
- percentUsed,
- resetAt: resetAt && !Number.isNaN(resetAt) ? resetAt : undefined,
- plan,
- };
- } catch {
- return null;
- }
-}
diff --git a/packages/core/src/credentials/google.ts b/packages/core/src/credentials/google.ts
deleted file mode 100644
index 17bc930..0000000
--- a/packages/core/src/credentials/google.ts
+++ /dev/null
@@ -1,178 +0,0 @@
-// ─── Google Gemini Usage Tracking ───────────────────────────
-// Two modes:
-// 1. API key → queries native Gemini models endpoint for rate limits
-// 2. Cookie → scrapes gemini.google.com for current usage % (like OpenCode)
-//
-// Set GEMINI_COOKIE env var with the value of your __Secure-1PSID cookie
-// from gemini.google.com to see current usage percentages.
-
-export interface GoogleUsageBucket {
- percentUsed: number; // 0-100
- resetsAt?: string; // e.g. "22:40" or "30 May at 17:40"
-}
-
-export interface GoogleUsageReport {
- // Mode 1: API key rate limits
- models?: Array<{
- name: string;
- inputTokenLimit: number;
- outputTokenLimit: number;
- rpm: number;
- requestsPerDay: number;
- }>;
- // Mode 2: Cookie-scraped usage from gemini.google.com
- currentUsage?: GoogleUsageBucket;
- weeklyUsage?: GoogleUsageBucket;
-}
-
-// Helpers to extract HTML text between markers
-function extractBetween(html: string, after: string, before: string): string | null {
- const start = html.indexOf(after);
- if (start === -1) return null;
- const s = start + after.length;
- const end = html.indexOf(before, s);
- if (end === -1) return null;
- return html.slice(s, end).trim();
-}
-
-function extractPercent(html: string, afterMarker: string): number | null {
- const chunk = extractBetween(html, afterMarker, "%");
- if (!chunk) return null;
- const digits = chunk.replace(/\D/g, "");
- const n = parseInt(digits, 10);
- return Number.isNaN(n) ? null : n;
-}
-
-function extractResetTime(html: string, afterMarker: string): string | null {
- // Look for "Resets at HH:MM" or "Resets on DD Mon at HH:MM"
- const start = html.indexOf(afterMarker);
- if (start === -1) return null;
- const chunk = html.slice(start + afterMarker.length);
- // Match time patterns
- const m = chunk.match(/Resets?\s+(at|on)\s+([^<]+)/i);
- if (!m?.[1] || !m[2]) return null;
- return `${m[1]} ${m[2].trim()}`;
-}
-
-async function scrapeGeminiWeb(cookie: string): Promise<{
- currentUsage?: GoogleUsageBucket;
- weeklyUsage?: GoogleUsageBucket;
-} | null> {
- try {
- const response = await fetch("https://gemini.google.com/app", {
- headers: {
- cookie: `__Secure-1PSID=${cookie}`,
- "accept-language": "en-US,en;q=0.9",
- },
- redirect: "follow",
- });
-
- if (!response.ok) return null;
-
- const html = await response.text();
-
- // Detect auth redirect
- if (html.includes("ServiceLogin") || html.includes("sign in")) {
- return null;
- }
-
- // Look for usage data in the page
- // Pattern: "Current usage" followed by a percentage and reset time
- const currentPct = extractPercent(html, "Current usage");
- const currentReset = extractResetTime(html, "Current usage");
-
- // Weekly limit section
- const weeklyPct = extractPercent(html, "Weekly limit");
- const weeklyReset = extractResetTime(html, "Weekly limit");
-
- const result: {
- currentUsage?: GoogleUsageBucket;
- weeklyUsage?: GoogleUsageBucket;
- } = {};
-
- if (currentPct !== null) {
- result.currentUsage = {
- percentUsed: currentPct,
- resetsAt: currentReset ?? undefined,
- };
- }
-
- if (weeklyPct !== null) {
- result.weeklyUsage = {
- percentUsed: weeklyPct,
- resetsAt: weeklyReset ?? undefined,
- };
- }
-
- if (result.currentUsage || result.weeklyUsage) return result;
- return null;
- } catch {
- return null;
- }
-}
-
-async function fetchModelsViaApiKey(apiKey: string): Promise<Array<{
- name: string;
- inputTokenLimit: number;
- outputTokenLimit: number;
- rpm: number;
- requestsPerDay: number;
-}> | null> {
- const url = `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(apiKey)}`;
-
- try {
- const response = await fetch(url);
- if (!response.ok) return null;
-
- const data = (await response.json()) as {
- models?: Array<{
- name: string;
- inputTokenLimit?: number;
- outputTokenLimit?: number;
- rateLimit?: { requestsPerMinute?: number };
- limits?: { requestsPerDay?: number };
- }>;
- };
-
- return (data.models ?? [])
- .filter((m) => {
- const name = m.name?.replace(/^models\//, "") ?? "";
- return name.startsWith("gemini-");
- })
- .map((m) => ({
- name: m.name?.replace(/^models\//, "") ?? "",
- inputTokenLimit: m.inputTokenLimit ?? 0,
- outputTokenLimit: m.outputTokenLimit ?? 0,
- rpm: m.rateLimit?.requestsPerMinute ?? 0,
- requestsPerDay: m.limits?.requestsPerDay ?? 0,
- }));
- } catch {
- return null;
- }
-}
-
-export async function fetchGoogleUsage(
- apiKey: string,
- _baseUrl: string,
-): Promise<GoogleUsageReport | null> {
- const results: GoogleUsageReport = {};
-
- // Try API key mode: get model rate limits
- const models = await fetchModelsViaApiKey(apiKey);
- if (models) {
- results.models = models;
- }
-
- // Try cookie mode: scrape gemini.google.com usage
- const cookie = process.env.GEMINI_COOKIE;
- if (cookie) {
- const scraped = await scrapeGeminiWeb(cookie);
- if (scraped) {
- if (scraped.currentUsage) results.currentUsage = scraped.currentUsage;
- if (scraped.weeklyUsage) results.weeklyUsage = scraped.weeklyUsage;
- }
- }
-
- if (results.models || results.currentUsage || results.weeklyUsage) return results;
- return null;
-}
diff --git a/packages/core/src/credentials/index.ts b/packages/core/src/credentials/index.ts
deleted file mode 100644
index 131f035..0000000
--- a/packages/core/src/credentials/index.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-export {
- deleteApiKey,
- getApiKey,
- listApiKeys,
- resolveApiKey,
- type StoredApiKey,
- setApiKey,
-} from "./api-keys.js";
-export {
- ANTHROPIC_MODELS_FALLBACK,
- buildBillingHeaderValue,
- buildWakeProbeBody,
- type ClaudeAccount,
- type ClaudeCredentials,
- type ClaudeProfile,
- type ClaudeUsageBucket,
- type ClaudeUsageReport,
- type ClaudeUsageResult,
- discoverClaudeAccounts,
- fetchAnthropicModels,
- getAccountUsage,
- getAccountUsageWithSource,
- getAnthropicBetas,
- getAnthropicHeaders,
- getClaudeAccountsFromDB,
- refreshAccountCredentials,
- refreshAccountCredentialsAsync,
- SYSTEM_IDENTITY,
- selectHaikuModel,
- validateAccountCredentials,
-} from "./claude.js";
-export {
- type CopilotUsageReport,
- fetchCopilotUsage,
-} from "./copilot.js";
-export {
- fetchGoogleUsage,
- type GoogleUsageReport,
-} from "./google.js";
-export {
- fetchOpencodeUsage,
- type OpencodeUsageBucket,
- type OpencodeUsageReport,
-} from "./opencode.js";
-export {
- deleteStoredCredentials,
- getStoredCredentials,
- importCredentialsFromFile,
- listStoredCredentials,
- type StoredCredential,
- updateStoredTokens,
-} from "./store.js";
diff --git a/packages/core/src/credentials/opencode.ts b/packages/core/src/credentials/opencode.ts
deleted file mode 100644
index d4d4851..0000000
--- a/packages/core/src/credentials/opencode.ts
+++ /dev/null
@@ -1,126 +0,0 @@
-import { resolveApiKey } from "./api-keys.js";
-
-// ─── OpenCode Usage Tracking ──────────────────────────────────
-// OpenCode has no public usage API. We scrape usage from the
-// SolidStart SSR-rendered workspace page using a session cookie.
-// Requires OPENCODE_COOKIE env var.
-// Workspace IDs: OPENCODE_WS1_ID for opencode-1, OPENCODE_WS2_ID for opencode-2.
-
-export interface OpencodeUsageBucket {
- utilization?: number; // 0-1 fraction
- resetsAt?: number; // Unix timestamp ms
-}
-
-export interface OpencodeUsageReport {
- fiveHour?: OpencodeUsageBucket;
- weekly?: OpencodeUsageBucket;
- monthly?: OpencodeUsageBucket;
-}
-
-function getWorkspaceId(keyId: string): string | undefined {
- // Check DB for workspace ID: stored as "opencode-ws1", "opencode-ws2", or "opencode-ws"
- const match = keyId.match(/opencode-(\d+)$/i);
- if (match) {
- const num = match[1];
- const specific = resolveApiKey(`opencode-ws${num}`);
- if (specific) return specific;
- }
- return resolveApiKey("opencode-ws") ?? undefined;
-}
-
-function parseOcDouble(html: string, key: string): number {
- const idx = html.indexOf(`${key}:`);
- if (idx === -1) return 0;
- let start = idx + key.length + 1;
- while (start < html.length && html[start] === " ") start++;
- let end = start;
- while (end < html.length && html[end] !== "," && html[end] !== "}") {
- end++;
- }
- const val = parseFloat(html.slice(start, end));
- return Number.isNaN(val) ? 0 : val;
-}
-
-function parseOcInt(html: string, key: string): number {
- const idx = html.indexOf(`${key}:`);
- if (idx === -1) return 0;
- let i = idx + key.length + 1;
- while (i < html.length && html[i] === " ") i++;
- return parseInt(html.slice(i), 10) || 0;
-}
-
-function parseOcBucket(
- html: string,
- bucketName: string,
-): { utilization: number; resetsAt: number } | null {
- const search = `${bucketName}:`;
- const pos = html.indexOf(search);
- if (pos === -1) return null;
-
- // Find the opening brace after the bucket name
- const brace = html.indexOf("{", pos);
- if (brace === -1) return null;
-
- const resetSecs = parseOcInt(html.slice(brace), "resetInSec");
- const usagePct = parseOcDouble(html.slice(brace), "usagePercent");
-
- const utilization = usagePct / 100; // convert 0-100% to 0-1 fraction
- const resetsAt = Date.now() + resetSecs * 1000;
-
- return { utilization, resetsAt };
-}
-
-export async function fetchOpencodeUsage(keyId: string): Promise<OpencodeUsageReport | null> {
- const cookie = resolveApiKey("opencode-cookie");
- const wsId = getWorkspaceId(keyId);
-
- if (!cookie || !wsId) {
- return null;
- }
-
- const url = `https://opencode.ai/workspace/${encodeURIComponent(wsId)}/go`;
-
- try {
- const response = await fetch(url, {
- headers: {
- accept: "text/html",
- cookie: `auth=${cookie}`,
- },
- redirect: "follow",
- });
-
- if (!response.ok) return null;
-
- const html = await response.text();
-
- // Auth redirect check
- if (html.includes("/auth/authorize") || html.includes('window.location="/auth/authorize"')) {
- return null;
- }
-
- // Find the lite.subscription data block.
- // HTML contains: lite.subscription.get[\"<wsId>\"]
- // We need literal backslashes; use \x5c (hex for backslash).
- const wsKey = `lite.subscription.get[\x5c"${wsId}\x5c"]`;
- const wsPos = html.indexOf(wsKey);
- if (wsPos === -1) return null;
-
- // Search for the resolved data starting from the ws key position
- const minePos = html.indexOf("mine:", wsPos);
- const slice = minePos !== -1 ? html.slice(minePos) : "";
-
- const fiveHour = parseOcBucket(slice, "rollingUsage");
- const weekly = parseOcBucket(slice, "weeklyUsage");
- const monthly = parseOcBucket(slice, "monthlyUsage");
-
- if (!fiveHour && !weekly && !monthly) return null;
-
- return {
- fiveHour: fiveHour ?? undefined,
- weekly: weekly ?? undefined,
- monthly: monthly ?? undefined,
- };
- } catch {
- return null;
- }
-}
diff --git a/packages/core/src/credentials/store.ts b/packages/core/src/credentials/store.ts
deleted file mode 100644
index 662b322..0000000
--- a/packages/core/src/credentials/store.ts
+++ /dev/null
@@ -1,185 +0,0 @@
-import { existsSync, readFileSync } from "node:fs";
-import { getDatabase } from "../db/index.js";
-import type { ClaudeCredentials } from "./claude.js";
-
-export interface StoredCredential {
- keyId: string;
- provider: string;
- accessToken: string;
- refreshToken: string;
- expiresAt: number;
- subscriptionType: string | null;
- sourceFile: string | null;
- importedAt: number;
- updatedAt: number;
-}
-
-function parseCredentialsFile(raw: string): ClaudeCredentials | null {
- let parsed: unknown;
- try {
- parsed = JSON.parse(raw);
- } catch {
- return null;
- }
-
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
-
- const data = (parsed as Record<string, unknown>).claudeAiOauth ?? parsed;
- const creds = data as Record<string, unknown>;
-
- if (creds.mcpOAuth && !creds.accessToken) return null;
-
- if (
- typeof creds.accessToken !== "string" ||
- typeof creds.refreshToken !== "string" ||
- typeof creds.expiresAt !== "number"
- ) {
- return null;
- }
-
- return {
- accessToken: creds.accessToken as string,
- refreshToken: creds.refreshToken as string,
- expiresAt: creds.expiresAt as number,
- subscriptionType:
- typeof creds.subscriptionType === "string" ? creds.subscriptionType : undefined,
- };
-}
-
-/**
- * Import credentials from a file into the database for a specific key.
- * Reads the credential file, parses it, and upserts into the credentials table.
- */
-export function importCredentialsFromFile(
- keyId: string,
- provider: string,
- filePath: string,
-): { success: boolean; error?: string } {
- if (!existsSync(filePath)) {
- return { success: false, error: `File not found: ${filePath}` };
- }
-
- let raw: string;
- try {
- raw = readFileSync(filePath, "utf-8").trim();
- } catch (e) {
- return {
- success: false,
- error: `Failed to read file: ${e instanceof Error ? e.message : String(e)}`,
- };
- }
-
- if (!raw) {
- return { success: false, error: "File is empty" };
- }
-
- const creds = parseCredentialsFile(raw);
- if (!creds) {
- return { success: false, error: "Invalid credentials format" };
- }
-
- const db = getDatabase();
- const now = Date.now();
-
- db.query(
- `INSERT INTO credentials (key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at)
- VALUES ($keyId, $provider, $accessToken, $refreshToken, $expiresAt, $subscriptionType, $sourceFile, $now, $now)
- ON CONFLICT(key_id) DO UPDATE SET
- access_token = $accessToken,
- refresh_token = $refreshToken,
- expires_at = $expiresAt,
- subscription_type = $subscriptionType,
- source_file = $sourceFile,
- updated_at = $now`,
- ).run({
- $keyId: keyId,
- $provider: provider,
- $accessToken: creds.accessToken,
- $refreshToken: creds.refreshToken,
- $expiresAt: creds.expiresAt,
- $subscriptionType: creds.subscriptionType ?? null,
- $sourceFile: filePath,
- $now: now,
- });
-
- return { success: true };
-}
-
-/**
- * Get stored credentials for a specific key from the database.
- */
-export function getStoredCredentials(keyId: string): StoredCredential | null {
- const db = getDatabase();
- const row = db
- .query(
- "SELECT key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at FROM credentials WHERE key_id = $keyId",
- )
- .get({ $keyId: keyId }) as Record<string, unknown> | null;
-
- if (!row) return null;
-
- return {
- keyId: row.key_id as string,
- provider: row.provider as string,
- accessToken: row.access_token as string,
- refreshToken: row.refresh_token as string,
- expiresAt: row.expires_at as number,
- subscriptionType: row.subscription_type as string | null,
- sourceFile: row.source_file as string | null,
- importedAt: row.imported_at as number,
- updatedAt: row.updated_at as number,
- };
-}
-
-/**
- * Update tokens in the database after a refresh.
- */
-export function updateStoredTokens(
- keyId: string,
- accessToken: string,
- refreshToken: string,
- expiresAt: number,
-): void {
- const db = getDatabase();
- db.query(
- `UPDATE credentials SET access_token = $accessToken, refresh_token = $refreshToken, expires_at = $expiresAt, updated_at = $now WHERE key_id = $keyId`,
- ).run({
- $keyId: keyId,
- $accessToken: accessToken,
- $refreshToken: refreshToken,
- $expiresAt: expiresAt,
- $now: Date.now(),
- });
-}
-
-/**
- * Delete stored credentials for a key.
- */
-export function deleteStoredCredentials(keyId: string): void {
- const db = getDatabase();
- db.query("DELETE FROM credentials WHERE key_id = $keyId").run({ $keyId: keyId });
-}
-
-/**
- * List all keys that have imported credentials, with their status.
- */
-export function listStoredCredentials(): StoredCredential[] {
- const db = getDatabase();
- const rows = db
- .query(
- "SELECT key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at FROM credentials ORDER BY key_id",
- )
- .all() as Array<Record<string, unknown>>;
-
- return rows.map((row) => ({
- keyId: row.key_id as string,
- provider: row.provider as string,
- accessToken: row.access_token as string,
- refreshToken: row.refresh_token as string,
- expiresAt: row.expires_at as number,
- subscriptionType: row.subscription_type as string | null,
- sourceFile: row.source_file as string | null,
- importedAt: row.imported_at as number,
- updatedAt: row.updated_at as number,
- }));
-}
diff --git a/packages/core/src/db/chunks.ts b/packages/core/src/db/chunks.ts
deleted file mode 100644
index b434a47..0000000
--- a/packages/core/src/db/chunks.ts
+++ /dev/null
@@ -1,246 +0,0 @@
-import { randomUUID } from "node:crypto";
-import {
- explodeTurn,
- explodeUserText,
- groupRowsToMessages,
- type MessageRow,
-} from "../chunks/transform.js";
-import type {
- ChunkData,
- ChunkRow,
- ChunkRowDraft,
- TextData,
- UsageData,
- UsageStats,
-} from "../types/index.js";
-import { getDatabase } from "./index.js";
-
-// Re-export the DB-free transforms so existing barrel consumers
-// (`@dispatch/core`) keep importing them from here. The browser frontend deep-
-// imports them directly from `chunks/transform.js` to avoid the DB dependency.
-export { explodeTurn, explodeUserText, groupRowsToMessages, type MessageRow };
-
-// ─── Persistence ─────────────────────────────────────────────────
-
-function mapRow(row: Record<string, unknown>): ChunkRow {
- let data: ChunkData;
- try {
- data = JSON.parse(row.data_json as string) as ChunkData;
- } catch {
- data = { text: "" } as TextData;
- }
- return {
- id: row.id as string,
- tabId: row.tab_id as string,
- seq: row.seq as number,
- turnId: row.turn_id as string,
- step: row.step as number,
- role: row.role as ChunkRow["role"],
- type: row.type as ChunkRow["type"],
- data,
- createdAt: row.created_at as number,
- };
-}
-
-/**
- * Append one or more chunk-row drafts to a tab, assigning a monotonic per-tab
- * `seq` and a fresh id/timestamp to each. Returns the inserted rows in order.
- */
-export function appendChunks(tabId: string, drafts: ChunkRowDraft[]): ChunkRow[] {
- if (drafts.length === 0) return [];
- const db = getDatabase();
- const maxSeq = db
- .query("SELECT COALESCE(MAX(seq), -1) as max_seq FROM chunks WHERE tab_id = $tabId")
- .get({ $tabId: tabId }) as { max_seq: number };
- let seq = (maxSeq?.max_seq ?? -1) + 1;
- const now = Date.now();
- const insert = db.query(
- `INSERT INTO chunks (id, tab_id, seq, turn_id, step, role, type, data_json, created_at)
- VALUES ($id, $tabId, $seq, $turnId, $step, $role, $type, $dataJson, $now)`,
- );
- const out: ChunkRow[] = [];
- // Wrap the whole batch in one transaction: a turn's chunks are persisted in
- // a single `appendChunks` call, so this is one fsync per turn instead of one
- // per row — the chosen low-IO write strategy for constrained backends.
- const insertAll = db.transaction(() => {
- for (const draft of drafts) {
- const id = randomUUID();
- insert.run({
- $id: id,
- $tabId: tabId,
- $seq: seq,
- $turnId: draft.turnId,
- $step: draft.step,
- $role: draft.role,
- $type: draft.type,
- $dataJson: JSON.stringify(draft.data),
- $now: now,
- });
- out.push({
- id,
- tabId,
- seq,
- turnId: draft.turnId,
- step: draft.step,
- role: draft.role,
- type: draft.type,
- data: draft.data,
- createdAt: now,
- });
- seq++;
- }
- });
- insertAll();
- return out;
-}
-
-/**
- * Read chunk rows for a tab in `seq` order (ASC). Pagination mirrors the old
- * message pagination but at chunk granularity:
- * - no options → all rows;
- * - `before` → rows with `seq < before`, most-recent-first then reversed;
- * - `limit` → most recent `limit` rows, reversed to ASC.
- */
-export function getChunksForTab(
- tabId: string,
- options?: { limit?: number; before?: number },
-): ChunkRow[] {
- const db = getDatabase();
- if (!options) {
- const rows = db
- .query("SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' ORDER BY seq ASC")
- .all({ $tabId: tabId }) as Array<Record<string, unknown>>;
- return rows.map(mapRow);
- }
- const { limit, before } = options;
- if (before !== undefined) {
- if (limit !== undefined) {
- const rows = db
- .query(
- "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' AND seq < $before ORDER BY seq DESC LIMIT $limit",
- )
- .all({ $tabId: tabId, $before: before, $limit: limit }) as Array<Record<string, unknown>>;
- return rows.map(mapRow).reverse();
- }
- const rows = db
- .query(
- "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' AND seq < $before ORDER BY seq DESC",
- )
- .all({ $tabId: tabId, $before: before }) as Array<Record<string, unknown>>;
- return rows.map(mapRow).reverse();
- }
- if (limit !== undefined) {
- const rows = db
- .query(
- "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' ORDER BY seq DESC LIMIT $limit",
- )
- .all({ $tabId: tabId, $limit: limit }) as Array<Record<string, unknown>>;
- return rows.map(mapRow).reverse();
- }
- const rows = db
- .query("SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' ORDER BY seq ASC")
- .all({ $tabId: tabId }) as Array<Record<string, unknown>>;
- return rows.map(mapRow);
-}
-
-/**
- * Derived, grouped view of a tab's full history as messages. Used to
- * pre-populate the agent's in-memory `ChatMessage[]` history when an Agent is
- * (re)constructed. Always reads the full log (grouping a partial window would
- * be lossy for the rebuild path).
- */
-export function getMessagesForTab(tabId: string): MessageRow[] {
- return groupRowsToMessages(getChunksForTab(tabId));
-}
-
-export function getTotalChunkCount(tabId: string): number {
- const db = getDatabase();
- const row = db
- .query("SELECT COUNT(*) as count FROM chunks WHERE tab_id = $tabId AND type != 'usage'")
- .get({ $tabId: tabId }) as { count: number } | null;
- return row?.count ?? 0;
-}
-
-/**
- * Aggregate per-tab token/cache usage across ALL persisted `usage` chunk rows.
- *
- * Usage rows are written as an invisible side channel (one row per `usage`
- * AgentEvent) and are query-excluded from `getChunksForTab`/`getTotalChunkCount`,
- * so this aggregate is the read path. Because it sums server-side over every
- * row, it stays complete even after the frontend evicts/pages out old turns
- * (eviction is in-memory only). The return shape is structurally identical to
- * the frontend `CacheStats`, so reload can seed it directly.
- *
- * - cumulative `inputTokens`/`outputTokens`/`cacheReadTokens`/`cacheWriteTokens`
- * = SUM over all usage rows;
- * - `requests` = COUNT of usage rows;
- * - `last` = the highest-seq usage row's split (most recent request);
- * - `null` when the tab has no usage rows.
- *
- * Sums in JS after selecting the rows (mirroring `mapRow`) to avoid relying on
- * `json_extract` over the freeform `data_json`.
- */
-export function getUsageStatsForTab(tabId: string): UsageStats | null {
- const db = getDatabase();
- const rows = db
- .query("SELECT data_json FROM chunks WHERE tab_id = $tabId AND type = 'usage' ORDER BY seq ASC")
- .all({ $tabId: tabId }) as Array<{ data_json: string }>;
- if (rows.length === 0) return null;
-
- let inputTokens = 0;
- let outputTokens = 0;
- let cacheReadTokens = 0;
- let cacheWriteTokens = 0;
- let last: UsageData | null = null;
- for (const row of rows) {
- let u: UsageData;
- try {
- u = JSON.parse(row.data_json) as UsageData;
- } catch {
- continue;
- }
- inputTokens += u.inputTokens ?? 0;
- outputTokens += u.outputTokens ?? 0;
- cacheReadTokens += u.cacheReadTokens ?? 0;
- cacheWriteTokens += u.cacheWriteTokens ?? 0;
- last = {
- inputTokens: u.inputTokens ?? 0,
- outputTokens: u.outputTokens ?? 0,
- cacheReadTokens: u.cacheReadTokens ?? 0,
- cacheWriteTokens: u.cacheWriteTokens ?? 0,
- };
- }
-
- return {
- inputTokens,
- outputTokens,
- cacheReadTokens,
- cacheWriteTokens,
- requests: rows.length,
- last,
- };
-}
-
-export function clearChunksForTab(tabId: string): void {
- const db = getDatabase();
- db.query("DELETE FROM chunks WHERE tab_id = $tabId").run({ $tabId: tabId });
-}
-
-/**
- * Relocate every chunk row from one tab to another (compaction backup path).
- *
- * Used by conversation compaction to move the FULL pre-compaction history off
- * the canonical tab id (`fromTabId`) onto a freshly-created backup tab id
- * (`toTabId`), leaving the canonical id free to be re-seeded with the summary +
- * preserved tail. `seq` values are preserved (they remain per-tab monotonic for
- * the destination since it starts empty), as are turn ids, so the relocated
- * history groups identically under its new tab. Returns the number of rows
- * moved.
- */
-export function rekeyChunks(fromTabId: string, toTabId: string): number {
- const db = getDatabase();
- const result = db
- .query("UPDATE chunks SET tab_id = $to WHERE tab_id = $from")
- .run({ $from: fromTabId, $to: toTabId });
- return Number(result.changes ?? 0);
-}
diff --git a/packages/core/src/db/index.ts b/packages/core/src/db/index.ts
deleted file mode 100644
index 93ec1f9..0000000
--- a/packages/core/src/db/index.ts
+++ /dev/null
@@ -1,177 +0,0 @@
-import { Database } from "bun:sqlite";
-import { existsSync, mkdirSync } from "node:fs";
-import { homedir } from "node:os";
-import { isAbsolute, join } from "node:path";
-
-/**
- * Returns the directory for persistent Dispatch data, following XDG Base
- * Directory spec on Linux: `$XDG_DATA_HOME/dispatch` (defaults to
- * `~/.local/share/dispatch`).
- */
-function getDataDir(): string {
- const xdg = process.env.XDG_DATA_HOME;
- const base = xdg && isAbsolute(xdg) ? xdg : join(homedir(), ".local", "share");
- return join(base, "dispatch");
-}
-
-let _db: Database | null = null;
-
-/**
- * Get (or create) the singleton SQLite database.
- *
- * - Creates the data directory if it doesn't exist.
- * - Creates `dispatch.db` if it doesn't exist.
- * - Enables WAL journal mode for concurrent read performance.
- */
-export function getDatabase(): Database {
- if (_db) return _db;
-
- const dir = getDataDir();
- if (!existsSync(dir)) {
- mkdirSync(dir, { recursive: true });
- }
-
- const dbPath = join(dir, "dispatch.db");
- _db = new Database(dbPath, { create: true });
-
- // WAL mode: better concurrent read performance, safe for single-writer
- _db.run("PRAGMA journal_mode = WAL;");
- // Recommended for WAL: normal synchronous is safe and faster
- _db.run("PRAGMA synchronous = NORMAL;");
- // Enable foreign keys
- _db.run("PRAGMA foreign_keys = ON;");
-
- // Create tables
- _db.run(`CREATE TABLE IF NOT EXISTS credentials (
- key_id TEXT PRIMARY KEY,
- provider TEXT NOT NULL,
- access_token TEXT NOT NULL,
- refresh_token TEXT NOT NULL,
- expires_at INTEGER NOT NULL,
- subscription_type TEXT,
- source_file TEXT,
- imported_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL
- )`);
-
- // Wake schedule: 4 rows per marked hour (one per :00 / :15 / :30 / :45 probe
- // slot). The PK is (hour, slot_minute). Destructive migration off the legacy
- // single-row-per-hour schema: detect by absence of the `slot_minute` column
- // and drop the old table. Other tables (credentials, api_keys, usage_cache,
- // settings, tabs, chunks) are NOT touched.
- const legacyWakeSchema = (() => {
- try {
- const cols = _db.query("PRAGMA table_info(wake_schedule)").all() as Array<{ name: string }>;
- if (cols.length === 0) return false; // table doesn't exist yet
- return !cols.some((c) => c.name === "slot_minute");
- } catch {
- return false;
- }
- })();
- if (legacyWakeSchema) {
- _db.run("DROP TABLE IF EXISTS wake_schedule");
- }
- _db.run(`CREATE TABLE IF NOT EXISTS wake_schedule (
- hour INTEGER NOT NULL CHECK (hour BETWEEN 0 AND 23),
- slot_minute INTEGER NOT NULL CHECK (slot_minute IN (0, 15, 30, 45)),
- next_wake_at INTEGER NOT NULL,
- PRIMARY KEY (hour, slot_minute)
- )`);
-
- _db.run(`CREATE TABLE IF NOT EXISTS usage_cache (
- key_id TEXT PRIMARY KEY,
- provider TEXT NOT NULL,
- cached_at INTEGER NOT NULL,
- report_json TEXT NOT NULL
- )`);
-
- _db.run(`CREATE TABLE IF NOT EXISTS api_keys (
- key_id TEXT PRIMARY KEY,
- provider TEXT NOT NULL,
- api_key TEXT NOT NULL,
- imported_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL
- )`);
-
- _db.run(`CREATE TABLE IF NOT EXISTS tabs (
- id TEXT PRIMARY KEY,
- title TEXT NOT NULL,
- key_id TEXT,
- model_id TEXT,
- parent_tab_id TEXT,
- status TEXT NOT NULL DEFAULT 'idle',
- is_open INTEGER NOT NULL DEFAULT 1,
- position INTEGER NOT NULL DEFAULT 0,
- created_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL
- )`);
-
- try {
- _db.run("ALTER TABLE tabs ADD COLUMN parent_tab_id TEXT");
- } catch {
- // Column already exists — ignore
- }
-
- // ─── Append-only chunk log (replaces the old `messages` blob table) ──
- //
- // A conversation is stored as a flat, append-only stream of chunk rows
- // keyed by a per-tab monotonic `seq`. "Message" and "turn" are DERIVED
- // groupings (see db/chunks.ts), never stored containers. This is what
- // powers per-chunk frontend pagination AND the stable per-step wire
- // format that fixes Anthropic prompt-cache churn (see notes/plan-chunk-log.md).
- //
- // role : 'user' | 'assistant' | 'tool' | 'system'
- // type : 'text' | 'thinking' | 'tool_call' | 'tool_result' | 'error' | 'system'
- // step : LLM round-trip index within a turn (user/system rows = 0)
- // data_json: the type-specific payload (see ChunkData in types)
- _db.run(`CREATE TABLE IF NOT EXISTS chunks (
- id TEXT PRIMARY KEY,
- tab_id TEXT NOT NULL,
- seq INTEGER NOT NULL,
- turn_id TEXT NOT NULL,
- step INTEGER NOT NULL DEFAULT 0,
- role TEXT NOT NULL,
- type TEXT NOT NULL,
- data_json TEXT NOT NULL,
- created_at INTEGER NOT NULL
- )`);
-
- _db.run(`CREATE INDEX IF NOT EXISTS idx_chunks_tab_seq ON chunks(tab_id, seq)`);
-
- // One-shot migration off the legacy `messages` blob model. Beta software,
- // no backward compatibility: the old chat history is destroyed (tabs +
- // messages), while settings / credentials / api_keys / usage_cache /
- // wake_schedule are preserved. Detect the old schema by the presence of
- // the `messages` table; once dropped, this branch never runs again.
- const hasLegacyMessages = _db
- .query("SELECT name FROM sqlite_master WHERE type='table' AND name='messages'")
- .get() as { name: string } | null;
- if (hasLegacyMessages) {
- _db.run("DROP TABLE IF EXISTS messages");
- // Clear conversation containers too (fresh slate for the new model).
- _db.run("DELETE FROM tabs");
- _db.run("DELETE FROM chunks");
- }
-
- _db.run(`CREATE TABLE IF NOT EXISTS settings (
- key TEXT PRIMARY KEY,
- value TEXT NOT NULL
- )`);
-
- return _db;
-}
-
-/** Close the database connection (e.g. on shutdown). */
-export function closeDatabase(): void {
- if (_db) {
- _db.close();
- _db = null;
- }
-}
-
-/** Returns the path where the database file lives (or will live). */
-export function getDatabasePath(): string {
- if (_db) return _db.filename;
- const dir = getDataDir();
- return join(dir, "dispatch.db");
-}
diff --git a/packages/core/src/db/settings.ts b/packages/core/src/db/settings.ts
deleted file mode 100644
index f9d152e..0000000
--- a/packages/core/src/db/settings.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { getDatabase } from "./index.js";
-
-export function getSetting(key: string): string | null {
- const db = getDatabase();
- const row = db.query("SELECT value FROM settings WHERE key = $key").get({ $key: key }) as {
- value: string;
- } | null;
- return row?.value ?? null;
-}
-
-export function setSetting(key: string, value: string): void {
- const db = getDatabase();
- db.query(
- `INSERT INTO settings (key, value) VALUES ($key, $value)
- ON CONFLICT(key) DO UPDATE SET value = $value`,
- ).run({ $key: key, $value: value });
-}
-
-export function deleteSetting(key: string): void {
- const db = getDatabase();
- db.query("DELETE FROM settings WHERE key = $key").run({ $key: key });
-}
diff --git a/packages/core/src/db/tabs.ts b/packages/core/src/db/tabs.ts
deleted file mode 100644
index f719a01..0000000
--- a/packages/core/src/db/tabs.ts
+++ /dev/null
@@ -1,250 +0,0 @@
-import { getDatabase } from "./index.js";
-
-export interface TabRow {
- id: string;
- title: string;
- keyId: string | null;
- modelId: string | null;
- parentTabId: string | null;
- status: string;
- isOpen: boolean;
- position: number;
- createdAt: number;
- updatedAt: number;
-}
-
-function rowToTab(row: Record<string, unknown>): TabRow {
- return {
- id: row.id as string,
- title: row.title as string,
- keyId: row.key_id as string | null,
- modelId: row.model_id as string | null,
- parentTabId: (row.parent_tab_id as string) ?? null,
- status: row.status as string,
- isOpen: (row.is_open as number) === 1,
- position: row.position as number,
- createdAt: row.created_at as number,
- updatedAt: row.updated_at as number,
- };
-}
-
-export function createTab(
- id: string,
- title: string,
- options?: { keyId?: string | null; modelId?: string | null; parentTabId?: string | null },
-): TabRow {
- const db = getDatabase();
- const now = Date.now();
- const maxPos = db
- .query("SELECT COALESCE(MAX(position), -1) as max_pos FROM tabs WHERE is_open = 1")
- .get() as { max_pos: number };
- const position = (maxPos?.max_pos ?? -1) + 1;
- const keyId = options?.keyId ?? null;
- const modelId = options?.modelId ?? null;
- const parentTabId = options?.parentTabId ?? null;
- db.query(
- `INSERT INTO tabs (id, title, key_id, model_id, parent_tab_id, status, is_open, position, created_at, updated_at)
- VALUES ($id, $title, $keyId, $modelId, $parentTabId, 'idle', 1, $position, $now, $now)`,
- ).run({
- $id: id,
- $title: title,
- $keyId: keyId,
- $modelId: modelId,
- $parentTabId: parentTabId,
- $position: position,
- $now: now,
- });
- return {
- id,
- title,
- keyId,
- modelId,
- parentTabId,
- status: "idle",
- isOpen: true,
- position,
- createdAt: now,
- updatedAt: now,
- };
-}
-
-export function getTab(id: string): TabRow | null {
- const db = getDatabase();
- const row = db.query("SELECT * FROM tabs WHERE id = $id").get({ $id: id }) as Record<
- string,
- unknown
- > | null;
- return row ? rowToTab(row) : null;
-}
-
-export function listOpenTabs(): TabRow[] {
- const db = getDatabase();
- const rows = db
- .query("SELECT * FROM tabs WHERE is_open = 1 ORDER BY position ASC")
- .all() as Array<Record<string, unknown>>;
- return rows.map(rowToTab);
-}
-
-export function updateTabTitle(id: string, title: string): void {
- const db = getDatabase();
- db.query("UPDATE tabs SET title = $title, updated_at = $now WHERE id = $id").run({
- $id: id,
- $title: title,
- $now: Date.now(),
- });
-}
-
-export function updateTabModel(id: string, keyId: string | null, modelId: string | null): void {
- const db = getDatabase();
- db.query(
- "UPDATE tabs SET key_id = $keyId, model_id = $modelId, updated_at = $now WHERE id = $id",
- ).run({
- $id: id,
- $keyId: keyId,
- $modelId: modelId,
- $now: Date.now(),
- });
-}
-
-export function updateTabStatus(id: string, status: string): void {
- const db = getDatabase();
- db.query("UPDATE tabs SET status = $status, updated_at = $now WHERE id = $id").run({
- $id: id,
- $status: status,
- $now: Date.now(),
- });
-}
-
-export function updateTabPositions(idsInOrder: string[]): void {
- const db = getDatabase();
- const now = Date.now();
- const update = db.query("UPDATE tabs SET position = $position, updated_at = $now WHERE id = $id");
- // One transaction so a reorder is atomic: either every tab lands at its new
- // slot or none does, never a half-applied ordering.
- const applyAll = db.transaction(() => {
- idsInOrder.forEach((id, index) => {
- update.run({ $id: id, $position: index, $now: now });
- });
- });
- applyAll();
-}
-
-export function archiveTab(id: string): void {
- const db = getDatabase();
- db.query("UPDATE tabs SET is_open = 0, updated_at = $now WHERE id = $id").run({
- $id: id,
- $now: Date.now(),
- });
-}
-
-/**
- * Return the IDs of `rootId` plus every OPEN descendant tab, in leaf-first
- * order (children before their parent). Archived descendants
- * (`is_open = 0`) and their sub-trees are skipped — closing a parent
- * shouldn't drag archived branches back into view.
- *
- * The starting `rootId` is always included in the result, even if no row
- * with that id exists in the `tabs` table (graceful handling for stale
- * references).
- *
- * Order matters for the cascade-close path: callers archive descendants
- * leaf-first so foreign-key cleanup (messages, etc.) doesn't fail on
- * partially-deleted parents.
- *
- * Cycle-safe: a `visited` set guards against accidental `parent_tab_id`
- * loops that would otherwise spin forever.
- */
-export function getDescendantIds(rootId: string): string[] {
- const db = getDatabase();
- const visited = new Set<string>();
- const order: string[] = [];
- const queue: string[] = [rootId];
- while (queue.length > 0) {
- const id = queue.shift() as string;
- if (visited.has(id)) continue;
- visited.add(id);
- order.push(id);
- const children = db
- .query("SELECT id FROM tabs WHERE parent_tab_id = $id AND is_open = 1")
- .all({ $id: id }) as Array<{ id: string }>;
- for (const child of children) {
- if (!visited.has(child.id)) queue.push(child.id);
- }
- }
- return order.reverse();
-}
-
-/**
- * Minimum length of a tab-handle prefix accepted by `resolveTabPrefix`.
- * Mirrors the frontend's minimum DISPLAY length (4 hex chars). Anything
- * shorter is rejected as too broad — an agent must echo at least the 4-char
- * handle shown in the UI.
- */
-export const MIN_TAB_PREFIX_LENGTH = 4;
-
-/**
- * Outcome of resolving a short tab handle (a git-style prefix of a tab's
- * UUID) back to a concrete open tab.
- *
- * - `ok` — exactly one open tab matched; `tab` is it.
- * - `none` — no open tab matched (bad/stale handle, or too-short prefix).
- * - `ambiguous` — more than one open tab shares the prefix; `matches` lists
- * them so the caller can ask for one more character (the same
- * UX as `git checkout <ambiguous-sha>`).
- */
-export type ResolveTabPrefixResult =
- | { status: "ok"; tab: TabRow }
- | { status: "none" }
- | { status: "ambiguous"; matches: TabRow[] };
-
-/**
- * Resolve a short tab handle to a single OPEN tab by prefix match — the
- * git-short-hash model. The handle is NEVER stored: it is always derived from
- * (and matched against) the canonical lowercase UUID in `tabs.id`.
- *
- * Sanitization is mandatory because the SQLite `LIKE` operator treats `%` and
- * `_` as wildcards: an unsanitized prefix like `a%` would match broadly. We
- * lowercase the input (UUIDs are canonical lowercase; SQLite `LIKE` is also
- * ASCII-case-insensitive by default) and strip everything outside the UUID
- * alphabet `[0-9a-f-]` so no wildcard can survive into the query.
- *
- * A prefix shorter than `MIN_TAB_PREFIX_LENGTH` after sanitization returns
- * `none` rather than matching a large swath of tabs.
- *
- * Only OPEN tabs (`is_open = 1`) are addressable — a closed tab's UUID prefix
- * must not cause phantom ambiguity or resolve to a dead conversation.
- */
-export function resolveTabPrefix(prefix: string): ResolveTabPrefixResult {
- const sanitized = (prefix ?? "").toLowerCase().replace(/[^0-9a-f-]/g, "");
- if (sanitized.length < MIN_TAB_PREFIX_LENGTH) {
- return { status: "none" };
- }
- const db = getDatabase();
- const rows = db
- .query("SELECT * FROM tabs WHERE is_open = 1 AND id LIKE $prefix ORDER BY position ASC")
- .all({ $prefix: `${sanitized}%` }) as Array<Record<string, unknown>>;
- if (rows.length === 0) return { status: "none" };
- if (rows.length === 1) return { status: "ok", tab: rowToTab(rows[0] as Record<string, unknown>) };
- return { status: "ambiguous", matches: rows.map(rowToTab) };
-}
-
-/**
- * Compute the shortest unique prefix (minimum `MIN_TAB_PREFIX_LENGTH` chars)
- * that identifies `tabId` among the currently OPEN tabs — the backend twin of
- * the frontend's display helper. Used when a tool needs to echo a tab's own
- * handle (e.g. provenance prefixes, "available tabs" hints) without trusting a
- * value from the wire.
- *
- * Returns the full id if no shorter unique prefix exists (degenerate — only if
- * two open tabs share an entire id, which UUID uniqueness precludes).
- */
-export function shortestUniquePrefix(tabId: string): string {
- const db = getDatabase();
- const rows = db.query("SELECT id FROM tabs WHERE is_open = 1").all() as Array<{ id: string }>;
- const others = rows.map((r) => r.id).filter((id) => id !== tabId);
- for (let len = MIN_TAB_PREFIX_LENGTH; len < tabId.length; len++) {
- const candidate = tabId.slice(0, len);
- if (!others.some((id) => id.startsWith(candidate))) return candidate;
- }
- return tabId;
-}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
deleted file mode 100644
index e951d08..0000000
--- a/packages/core/src/index.ts
+++ /dev/null
@@ -1,172 +0,0 @@
-// @dispatch/core — Agent runtime, LLM integration, tools
-
-// Agent & LLM
-export { Agent } from "./agent/agent.js";
-export {
- deleteAgent,
- expandAgentToolNames,
- GLOBAL_AGENTS_DIR,
- getAgentDirPaths,
- getAgentDirs,
- getProjectAgentsDir,
- loadAgent,
- loadAgents,
- saveAgent,
-} from "./agents/index.js";
-// Chunk helpers
-export {
- appendEventToChunks,
- applySystemEvent,
- type IdentifiedMessage,
- type SystemEventLike,
-} from "./chunks/append.js";
-// Compaction
-export {
- buildCompactionPrompt,
- buildCompactionRequest,
- buildSummaryTurnText,
- type CompactionRequest,
- DEFAULT_TAIL_TURNS,
- extractPreviousSummary,
- type HeadTailSelection,
- renderTranscript,
- SUMMARY_MARKER,
- SUMMARY_TEMPLATE,
- selectHeadTail,
- TOOL_OUTPUT_MAX_CHARS,
-} from "./compaction/index.js";
-// Config
-export {
- configToRuleset,
- createConfigWatcher,
- getGlobalConfigPath,
- loadConfig,
- loadGlobalConfig,
- mergeConfigs,
- validateConfig,
- watchDirConfig,
-} from "./config/index.js";
-// Credentials
-export * from "./credentials/index.js";
-export {
- appendChunks,
- clearChunksForTab,
- explodeTurn,
- explodeUserText,
- getChunksForTab,
- getMessagesForTab,
- getTotalChunkCount,
- getUsageStatsForTab,
- groupRowsToMessages,
- type MessageRow,
- rekeyChunks,
-} from "./db/chunks.js";
-// Database
-export { closeDatabase, getDatabase, getDatabasePath } from "./db/index.js";
-export { deleteSetting, getSetting, setSetting } from "./db/settings.js";
-// Tabs & Messages
-export {
- archiveTab,
- createTab,
- getTab,
- listOpenTabs,
- MIN_TAB_PREFIX_LENGTH,
- type ResolveTabPrefixResult,
- resolveTabPrefix,
- shortestUniquePrefix,
- type TabRow,
- updateTabModel,
- updateTabPositions,
- updateTabStatus,
- updateTabTitle,
-} from "./db/tabs.js";
-export {
- debugVerbosity,
- isDebugEnabled,
- logAgentLoop,
- logStepLifecycle,
- logStreamEvent,
-} from "./llm/debug-logger.js";
-export { createProvider } from "./llm/provider.js";
-// LSP (Language Server Protocol)
-export {
- createLspClient,
- type Diagnostic as LspDiagnostic,
- type LspClient,
- LspManager,
- type LspServerHandle,
- pretty as prettyDiagnostic,
- type ResolvedLspServer,
- report as reportDiagnostics,
- resolveServersFromConfig,
-} from "./lsp/index.js";
-// Models
-export {
- ACCEPTED_ATTACHMENT_MEDIA_TYPES,
- ACCEPTED_IMAGE_MEDIA_TYPES,
- ACCEPTED_PDF_MEDIA_TYPE,
- type AttachmentValidationError,
- type AttachmentValidationResult,
- base64ByteLength,
- getModelsCatalog,
- hasAttachments,
- isAcceptedAttachmentMediaType,
- isImageMediaType,
- isPdfMediaType,
- MAX_ATTACHMENTS,
- MAX_IMAGE_BYTES,
- MAX_PDF_BYTES,
- MAX_TOTAL_ATTACHMENT_BYTES,
- type ModelInputCapabilities,
- ModelRegistry,
- resolveContextLimit,
- resolveModelCapabilities,
- validateUserContent,
-} from "./models/index.js";
-// Notifications (ntfy.sh)
-export * from "./notifications/index.js";
-export * from "./permission/index.js";
-// Skills
-export {
- createSkillsWatcher,
- getSkillByName,
- loadSkills,
- parseSkillFile,
- resolveSkillsForAgent,
-} from "./skills/index.js";
-export { prefix as bashArityPrefix } from "./tools/bash-arity.js";
-// Tools
-export { createKeyUsageTool, type KeyUsageCallbacks } from "./tools/key-usage.js";
-export { createListFilesTool } from "./tools/list-files.js";
-export { createLspTool, type LspToolContext } from "./tools/lsp.js";
-export { createReadFileTool } from "./tools/read-file.js";
-export { createReadFileSliceTool } from "./tools/read-file-slice.js";
-export { createReadTabTool, type ReadTabCallbacks } from "./tools/read-tab.js";
-export { createToolRegistry } from "./tools/registry.js";
-export { createRetrieveTool, type RetrieveCallbacks } from "./tools/retrieve.js";
-export { BackgroundShellStore, createRunShellTool } from "./tools/run-shell.js";
-export { createSearchCodeTool } from "./tools/search-code.js";
-export {
- createSendToTabTool,
- type ResolvedTabRef,
- type SendToTabCallbacks,
- type TabResolution,
-} from "./tools/send-to-tab.js";
-export { analyzeCommand } from "./tools/shell-analyze.js";
-export {
- type AvailableAgent,
- createSummonTool,
- type SummonCallbacks,
- toAvailableSubagents,
- toAvailableUserAgents,
-} from "./tools/summon.js";
-export { createTaskListTool, TaskList, TODO_DESCRIPTION } from "./tools/task-list.js";
-export { clearSpillForTab } from "./tools/truncate.js";
-export { createWebSearchTool } from "./tools/web-search.js";
-export { type AfterWriteHook, createWriteFileTool } from "./tools/write-file.js";
-export {
- BackgroundTranscriptStore,
- createYoutubeTranscribeTool,
-} from "./tools/youtube-transcribe.js";
-// Types & Permissions
-export * from "./types/index.js";
diff --git a/packages/core/src/llm/anthropic-oauth-transform.ts b/packages/core/src/llm/anthropic-oauth-transform.ts
deleted file mode 100644
index 467a307..0000000
--- a/packages/core/src/llm/anthropic-oauth-transform.ts
+++ /dev/null
@@ -1,153 +0,0 @@
-/**
- * Wire-level request restructuring for the Claude OAuth (Pro/Max) flow.
- *
- * Anthropic validates the `system` array on OAuth-authenticated, Claude-Code-
- * billed requests. A genuine Claude Code request looks like:
- *
- * system: [
- * { type: "text", text: "x-anthropic-billing-header: ..." }, // system[0], NO cache_control
- * { type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude.",
- * cache_control: { type: "ephemeral" } }, // identity, separate block
- * ]
- * messages: [ { role: "user", content: "<the real system prompt>\n\n<user text>" }, ... ]
- *
- * i.e. ONLY the billing header and the verbatim identity string may live in
- * `system[]`. Any third-party system prompt (Dispatch's tool/agent instructions)
- * MUST be relocated into the first user message. When third-party content stays
- * in `system[]` next to the identity, Anthropic bills it as premium "extra
- * usage" (token burn) and refuses to apply the Claude Code prompt-cache scope —
- * producing the 0% cache hit rate and ballooning cost we were seeing.
- *
- * Dispatch builds its system prompt as ONE concatenated block
- * (`<billing>\n<identity>\n\n<systemPrompt>`); `@ai-sdk/anthropic` serializes
- * that into a single `system[]` text entry. This transform runs at fetch time
- * on the already-serialized JSON body and reshapes it into the structure above.
- *
- * Mirrors `references/opencode-claude-auth/src/transforms.ts` (`transformBody`),
- * adapted to Dispatch: tool names are already PascalCase-`mcp_`-prefixed by the
- * agent, so this transform leaves tools and messages (other than the relocation)
- * untouched. It is defensive: any parse/shape surprise returns the body
- * unchanged so a transform bug can never break a request.
- */
-
-const SYSTEM_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
-const BILLING_PREFIX = "x-anthropic-billing-header";
-
-type SystemBlock = { type: "text"; text: string; cache_control?: unknown } & Record<
- string,
- unknown
->;
-
-interface AnthropicRequestBody {
- system?: string | Array<{ type?: string; text?: string } & Record<string, unknown>>;
- messages?: Array<{
- role?: string;
- content?: string | Array<{ type?: string; text?: string } & Record<string, unknown>>;
- }>;
- [key: string]: unknown;
-}
-
-/**
- * Restructure a serialized Anthropic request body string for the Claude Code
- * OAuth flow. Returns the (possibly rewritten) body, or the original input
- * unchanged when it isn't a JSON string we recognize.
- */
-export function transformClaudeOAuthBody(
- body: BodyInit | null | undefined,
-): BodyInit | null | undefined {
- if (typeof body !== "string") return body;
- let parsed: AnthropicRequestBody;
- try {
- parsed = JSON.parse(body) as AnthropicRequestBody;
- } catch {
- return body;
- }
- if (!parsed || typeof parsed !== "object") return body;
- try {
- const changed = restructureSystem(parsed);
- return changed ? JSON.stringify(parsed) : body;
- } catch {
- // Never let a transform bug break a real request.
- return body;
- }
-}
-
-/**
- * In-place restructure of `parsed.system` / `parsed.messages`. Returns true if
- * anything changed (so the caller knows to re-serialize).
- */
-function restructureSystem(parsed: AnthropicRequestBody): boolean {
- const raw = parsed.system;
- let entries: SystemBlock[];
- if (typeof raw === "string") {
- entries = [{ type: "text", text: raw }];
- } else if (Array.isArray(raw)) {
- entries = raw.map((e) =>
- typeof e === "string"
- ? { type: "text", text: e }
- : ({ ...e, type: "text", text: typeof e.text === "string" ? e.text : "" } as SystemBlock),
- );
- } else {
- return false; // no system field — nothing to do
- }
-
- const combined = entries.map((e) => e.text).join("\n\n");
-
- // Only act on Claude Code shaped requests (must carry the identity string).
- if (!combined.includes(SYSTEM_IDENTITY)) return false;
-
- const hadCacheControl = entries.some((e) => e.cache_control != null);
-
- // Peel the billing-header line out (it is a single line with no newlines).
- const lines = combined.split("\n");
- const billingIdx = lines.findIndex((l) => l.startsWith(BILLING_PREFIX));
- let billingLine: string | null = null;
- if (billingIdx !== -1) {
- billingLine = lines[billingIdx] ?? null;
- lines.splice(billingIdx, 1);
- }
- const afterBilling = lines.join("\n").replace(/^\n+/, "");
-
- // Split the identity prefix from the rest (Dispatch's real system prompt).
- let rest = "";
- if (afterBilling.startsWith(SYSTEM_IDENTITY)) {
- rest = afterBilling.slice(SYSTEM_IDENTITY.length).replace(/^\n+/, "");
- } else {
- // Identity is present but not at the front (unexpected) — still isolate it.
- rest = afterBilling.replace(SYSTEM_IDENTITY, "").replace(/^\n+/, "");
- }
-
- // Rebuild system[]: billing (no cache_control) then identity (cached).
- const newSystem: SystemBlock[] = [];
- if (billingLine) newSystem.push({ type: "text", text: billingLine });
- const identityBlock: SystemBlock = { type: "text", text: SYSTEM_IDENTITY };
- if (hadCacheControl) identityBlock.cache_control = { type: "ephemeral" };
- newSystem.push(identityBlock);
-
- // Relocate the third-party system prompt into the first user message.
- if (rest.length > 0) {
- const firstUser = Array.isArray(parsed.messages)
- ? parsed.messages.find((m) => m.role === "user")
- : undefined;
- if (firstUser) {
- if (typeof firstUser.content === "string") {
- firstUser.content = `${rest}\n\n${firstUser.content}`;
- } else if (Array.isArray(firstUser.content)) {
- firstUser.content.unshift({ type: "text", text: rest });
- } else {
- firstUser.content = rest;
- }
- } else {
- // No user message to host it — keep it as a (cached) system block so
- // the request still carries the instructions.
- const restBlock: SystemBlock = { type: "text", text: rest };
- if (hadCacheControl) restBlock.cache_control = { type: "ephemeral" };
- newSystem.push(restBlock);
- }
- }
-
- parsed.system = newSystem;
- return true;
-}
-
-export const __test = { restructureSystem, SYSTEM_IDENTITY, BILLING_PREFIX };
diff --git a/packages/core/src/llm/debug-logger.ts b/packages/core/src/llm/debug-logger.ts
deleted file mode 100644
index 072a7a1..0000000
--- a/packages/core/src/llm/debug-logger.ts
+++ /dev/null
@@ -1,448 +0,0 @@
-/**
- * Debug logger for LLM API requests and responses.
- *
- * Enable via environment variable: DISPATCH_DEBUG_LLM=1
- *
- * Logs every outgoing request body and incoming response body to timestamped
- * files under `DISPATCH_DEBUG_LLM_DIR` (default: /tmp/dispatch/llm-debug/).
- *
- * Each request/response pair shares a sequence number for easy correlation.
- * Files are named: `{seq}_{timestamp}_{direction}_{model}.json`
- *
- * For streaming responses (SSE), the raw chunks are captured as they arrive
- * and written out as a JSON array when the stream completes.
- *
- * Additional logging layers:
- * - Stream events: every AI SDK stream event (text-delta, tool-call, etc.)
- * - Step lifecycle: step start/end, tool execution timing
- * - Agent loop: step count, break conditions, tool call counts
- *
- * All output goes to stderr (console.error) for stream event logs, and to
- * files for request/response bodies (too large for console).
- */
-
-import { mkdirSync, writeFileSync } from "node:fs";
-import { join } from "node:path";
-
-const ENABLED = !!process.env.DISPATCH_DEBUG_LLM;
-const LOG_DIR = process.env.DISPATCH_DEBUG_LLM_DIR || "/tmp/dispatch/llm-debug";
-let seq = 0;
-
-/** Verbosity levels:
- * 1 = requests/responses only (files)
- * 2 = + stream events to stderr
- * 3 = + step lifecycle + agent loop details to stderr
- */
-const VERBOSITY = Math.max(1, Number(process.env.DISPATCH_DEBUG_LLM_VERBOSITY) || 1);
-
-function ensureDir(): void {
- try {
- mkdirSync(LOG_DIR, { recursive: true });
- } catch {
- // best effort
- }
-}
-
-function ts(): string {
- return new Date().toISOString().replace(/[:.]/g, "-");
-}
-
-function sanitizeModel(model: string): string {
- return model.replace(/[^a-zA-Z0-9_.-]/g, "_").slice(0, 60);
-}
-
-function sanitizeTab(tabId?: string): string {
- if (!tabId) return "notab";
- return tabId.replace(/[^a-zA-Z0-9_.-]/g, "_").slice(0, 40);
-}
-
-export function isDebugEnabled(): boolean {
- return ENABLED;
-}
-
-export function debugVerbosity(): number {
- return ENABLED ? VERBOSITY : 0;
-}
-
-/**
- * Allocate a fresh sequence number. Used by the fetch wrapper so the request
- * and the response share the same id without needing a separate `logRequest`
- * call from the agent loop (which doesn't see the actual HTTP body anyway).
- */
-export function nextDebugSeq(): number {
- return ++seq;
-}
-
-/**
- * Log an outgoing request to the AI model endpoint.
- * Returns a request ID for correlating with the response.
- */
-export function logRequest(data: {
- model: string;
- url?: string;
- method?: string;
- headers?: Record<string, string>;
- body: unknown;
- tabId?: string;
- step?: number;
- provider?: string;
-}): number {
- if (!ENABLED) return -1;
- ensureDir();
- const id = ++seq;
- const filename = `${String(id).padStart(5, "0")}_${ts()}_tab-${sanitizeTab(data.tabId)}_REQ_${sanitizeModel(data.model)}.json`;
- const payload = {
- _debug: {
- seq: id,
- direction: "request",
- timestamp: new Date().toISOString(),
- tabId: data.tabId,
- step: data.step,
- provider: data.provider,
- },
- url: data.url,
- method: data.method ?? "POST",
- headers: data.headers,
- body: data.body,
- };
- try {
- writeFileSync(join(LOG_DIR, filename), JSON.stringify(payload, null, 2));
- } catch (err) {
- console.error(`[dispatch-debug] Failed to write request log: ${err}`);
- }
- console.error(
- `[dispatch-debug] REQ #${id} → ${data.model} (step=${data.step ?? "?"}, tab=${data.tabId ?? "?"})`,
- );
- return id;
-}
-
-/**
- * Log the raw fetch-level request (the actual HTTP body sent to the provider).
- * Called from the instrumented fetch wrapper.
- */
-export function logRawFetchRequest(data: {
- requestId: number;
- url: string;
- method: string;
- headers: Record<string, string>;
- body: string | null;
- tabId?: string;
-}): void {
- if (!ENABLED) return;
- ensureDir();
- const filename = `${String(data.requestId).padStart(5, "0")}_${ts()}_tab-${sanitizeTab(data.tabId)}_RAW_REQ.json`;
- const payload = {
- _debug: {
- seq: data.requestId,
- direction: "raw-request",
- timestamp: new Date().toISOString(),
- tabId: data.tabId,
- },
- url: data.url,
- method: data.method,
- headers: data.headers,
- body: tryParseJson(data.body),
- };
- try {
- writeFileSync(join(LOG_DIR, filename), JSON.stringify(payload, null, 2));
- } catch (err) {
- console.error(`[dispatch-debug] Failed to write raw request log: ${err}`);
- }
-}
-
-/**
- * Log the raw fetch-level response (HTTP status, headers, body).
- */
-export function logRawFetchResponse(data: {
- requestId: number;
- url: string;
- status: number;
- statusText: string;
- headers: Record<string, string>;
- body: string | null;
- isStreaming: boolean;
- tabId?: string;
-}): void {
- if (!ENABLED) return;
- ensureDir();
- const filename = `${String(data.requestId).padStart(5, "0")}_${ts()}_tab-${sanitizeTab(data.tabId)}_RAW_RES_${data.status}.json`;
- const payload = {
- _debug: {
- seq: data.requestId,
- direction: "raw-response",
- timestamp: new Date().toISOString(),
- isStreaming: data.isStreaming,
- tabId: data.tabId,
- },
- url: data.url,
- status: data.status,
- statusText: data.statusText,
- headers: data.headers,
- body: tryParseJson(data.body),
- };
- try {
- writeFileSync(join(LOG_DIR, filename), JSON.stringify(payload, null, 2));
- } catch (err) {
- console.error(`[dispatch-debug] Failed to write raw response log: ${err}`);
- }
-}
-
-/**
- * Accumulator for streaming response chunks. Call `addChunk()` as SSE events
- * arrive, then `flush()` when the stream ends to write them all to disk.
- */
-export class StreamResponseLogger {
- private requestId: number;
- private model: string;
- private tabId?: string;
- private chunks: Array<{ timestamp: string; data: string }> = [];
- private startTime: number;
-
- constructor(requestId: number, model: string, tabId?: string) {
- this.requestId = requestId;
- this.model = model;
- this.tabId = tabId;
- this.startTime = Date.now();
- }
-
- addChunk(rawLine: string): void {
- if (!ENABLED) return;
- this.chunks.push({
- timestamp: new Date().toISOString(),
- data: rawLine,
- });
- }
-
- flush(meta?: { finishReason?: string; error?: string }): void {
- if (!ENABLED) return;
- ensureDir();
- const elapsed = Date.now() - this.startTime;
- const filename = `${String(this.requestId).padStart(5, "0")}_${ts()}_tab-${sanitizeTab(this.tabId)}_STREAM_RES_${sanitizeModel(this.model)}.json`;
- const payload = {
- _debug: {
- seq: this.requestId,
- direction: "stream-response",
- timestamp: new Date().toISOString(),
- tabId: this.tabId,
- model: this.model,
- elapsedMs: elapsed,
- chunkCount: this.chunks.length,
- ...meta,
- },
- chunks: this.chunks,
- };
- try {
- writeFileSync(join(LOG_DIR, filename), JSON.stringify(payload, null, 2));
- } catch (err) {
- console.error(`[dispatch-debug] Failed to write stream response log: ${err}`);
- }
- console.error(
- `[dispatch-debug] STREAM #${this.requestId} complete: ${this.chunks.length} chunks in ${elapsed}ms (${this.model})`,
- );
- }
-}
-
-/**
- * Log an AI SDK stream event (text-delta, tool-call, finish-step, etc.).
- * Only logs at verbosity >= 2.
- */
-export function logStreamEvent(data: {
- requestId: number;
- step: number;
- eventType: string;
- detail?: unknown;
- tabId?: string;
-}): void {
- if (!ENABLED || VERBOSITY < 2) return;
- const detail = data.detail !== undefined ? ` ${JSON.stringify(data.detail)}` : "";
- console.error(
- `[dispatch-debug] STREAM_EVENT #${data.requestId} step=${data.step} ${data.eventType}${detail}`,
- );
-}
-
-/**
- * Log step lifecycle events (step start, tool execution, step end).
- * Only logs at verbosity >= 3.
- */
-export function logStepLifecycle(data: {
- tabId?: string;
- step: number;
- event: string;
- detail?: unknown;
-}): void {
- if (!ENABLED || VERBOSITY < 3) return;
- const detail = data.detail !== undefined ? ` ${JSON.stringify(data.detail)}` : "";
- console.error(
- `[dispatch-debug] STEP tab=${data.tabId ?? "?"} step=${data.step} ${data.event}${detail}`,
- );
-}
-
-/**
- * Log agent loop-level events (loop start, break conditions, etc.).
- * Only logs at verbosity >= 3.
- */
-export function logAgentLoop(data: { tabId?: string; event: string; detail?: unknown }): void {
- if (!ENABLED || VERBOSITY < 3) return;
- const detail = data.detail !== undefined ? ` ${JSON.stringify(data.detail)}` : "";
- console.error(`[dispatch-debug] AGENT tab=${data.tabId ?? "?"} ${data.event}${detail}`);
-}
-
-/**
- * Wrap a fetch function so every request/response pair is logged to disk
- * under `DISPATCH_DEBUG_LLM_DIR` when `DISPATCH_DEBUG_LLM` is set. When
- * disabled, returns the input fetch unchanged (zero overhead).
- *
- * Critical implementation note — SSE bodies: the AI SDK consumes
- * `response.body` as a `ReadableStream`. Reading it from anywhere else
- * (e.g. calling `.text()`) drains the stream and the SDK gets an empty
- * body. We therefore `response.clone()` the response and tee its body via
- * a `TransformStream` so each SSE line is forwarded to the SDK AND
- * captured into a `StreamResponseLogger`. The clone returns its own
- * Response object whose body the SDK reads normally.
- *
- * For non-streaming responses (`content-type` is not `text/event-stream`)
- * we just clone and read once via `.text()` — simpler and safe because
- * non-streaming bodies are bounded.
- */
-export function wrapFetchWithLogging<F extends (...args: never[]) => Promise<Response> | Response>(
- baseFetch: F,
- opts: { tabId?: string; modelHint?: string },
-): F {
- if (!ENABLED) return baseFetch;
- const wrapped = async (...args: Parameters<F>) => {
- const requestId = ++seq;
- const [input, init] = args as unknown as [RequestInfo | URL, RequestInit | undefined];
- const url =
- typeof input === "string"
- ? input
- : input instanceof URL
- ? input.toString()
- : (input as Request).url;
- const method =
- init?.method ??
- (typeof input === "object" && "method" in input ? (input as Request).method : "POST");
-
- // Snapshot headers as a plain object for logging.
- const headerObj: Record<string, string> = {};
- try {
- const h = new Headers(init?.headers);
- h.forEach((v, k) => {
- // Redact bearer / api-key headers — useful in shared logs.
- if (/^(authorization|x-api-key|cookie)$/i.test(k)) {
- headerObj[k] = "<redacted>";
- } else {
- headerObj[k] = v;
- }
- });
- } catch {
- // best effort
- }
-
- // Capture request body. Most providers send a JSON string here; if it's
- // a stream/blob/etc. we skip body logging (rare in our codebase).
- let bodyStr: string | null = null;
- if (typeof init?.body === "string") {
- bodyStr = init.body;
- } else if (init?.body instanceof Uint8Array) {
- bodyStr = new TextDecoder().decode(init.body);
- }
-
- logRawFetchRequest({
- requestId,
- url,
- method,
- headers: headerObj,
- body: bodyStr,
- tabId: opts.tabId,
- });
-
- const response = await (baseFetch as unknown as typeof fetch)(input, init);
-
- const respHeaders: Record<string, string> = {};
- response.headers.forEach((v, k) => {
- respHeaders[k] = v;
- });
- const contentType = response.headers.get("content-type") ?? "";
- const isStreaming = contentType.includes("text/event-stream");
-
- if (!isStreaming) {
- // Clone so we don't drain the SDK's copy. Bounded body — safe to read.
- try {
- const cloned = response.clone();
- const text = await cloned.text();
- logRawFetchResponse({
- requestId,
- url,
- status: response.status,
- statusText: response.statusText,
- headers: respHeaders,
- body: text,
- isStreaming: false,
- tabId: opts.tabId,
- });
- } catch (err) {
- console.error(`[dispatch-debug] Failed to clone non-stream response: ${err}`);
- }
- return response;
- }
-
- // Streaming path: write a header file with status + headers immediately
- // (the body file comes later via StreamResponseLogger.flush).
- logRawFetchResponse({
- requestId,
- url,
- status: response.status,
- statusText: response.statusText,
- headers: respHeaders,
- body: null,
- isStreaming: true,
- tabId: opts.tabId,
- });
-
- // Tee the body through a TransformStream so each SSE chunk is captured
- // without consuming the stream the SDK needs.
- const streamLogger = new StreamResponseLogger(
- requestId,
- opts.modelHint ?? "stream",
- opts.tabId,
- );
- const decoder = new TextDecoder();
- const tee = new TransformStream<Uint8Array, Uint8Array>({
- transform(chunk, controller) {
- try {
- streamLogger.addChunk(decoder.decode(chunk, { stream: true }));
- } catch {
- // never let logging break the stream
- }
- controller.enqueue(chunk);
- },
- flush() {
- try {
- streamLogger.flush();
- } catch {
- // best effort
- }
- },
- });
-
- // `response.body` is `ReadableStream<Uint8Array> | null`. If null (no
- // body), there's nothing to tee — return as-is.
- if (!response.body) return response;
- const teed = response.body.pipeThrough(tee);
- return new Response(teed, {
- status: response.status,
- statusText: response.statusText,
- headers: response.headers,
- });
- };
- return wrapped as unknown as F;
-}
-
-function tryParseJson(s: string | null): unknown {
- if (s === null) return null;
- try {
- return JSON.parse(s);
- } catch {
- return s;
- }
-}
diff --git a/packages/core/src/llm/provider.ts b/packages/core/src/llm/provider.ts
deleted file mode 100644
index ca734f9..0000000
--- a/packages/core/src/llm/provider.ts
+++ /dev/null
@@ -1,180 +0,0 @@
-import { randomUUID } from "node:crypto";
-import { createAnthropic } from "@ai-sdk/anthropic";
-import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
-import type { LanguageModelV3 } from "@ai-sdk/provider";
-import type { FetchFunction } from "@ai-sdk/provider-utils";
-import { getAnthropicBetas } from "../credentials/anthropic-betas.js";
-import { transformClaudeOAuthBody } from "./anthropic-oauth-transform.js";
-import { wrapFetchWithLogging } from "./debug-logger.js";
-
-export interface ProviderConfig {
- apiKey: string;
- baseURL: string;
- provider?: string;
- claudeCredentials?: {
- accessToken: string;
- };
- /** Optional tab id for labelling debug logs. No effect when
- * `DISPATCH_DEBUG_LLM` is unset. */
- tabId?: string;
-}
-
-const MCP_PREFIX = "mcp_";
-
-function prefixToolName(name: string): string {
- return `${MCP_PREFIX}${name.charAt(0).toUpperCase()}${name.slice(1)}`;
-}
-
-function unprefixToolName(name: string): string {
- if (name.startsWith(MCP_PREFIX)) {
- const rest = name.slice(MCP_PREFIX.length);
- return `${rest.charAt(0).toLowerCase()}${rest.slice(1)}`;
- }
- return name;
-}
-
-// Explicit factory return type so the inferred type doesn't leak references
-// into transitive `@ai-sdk/provider` paths (which would trip TS2742).
-// `@ai-sdk/anthropic` v3.x and `@ai-sdk/openai-compatible` v2.x both return
-// `LanguageModelV3`-spec models; `wrapLanguageModel` likewise.
-export type ModelFactory = (modelId: string) => LanguageModelV3;
-
-export function createProvider(config: ProviderConfig): ModelFactory {
- if (config.provider === "anthropic") {
- return createClaudeOAuthProvider(config);
- }
-
- if (config.provider === "opencode-anthropic") {
- return createApiKeyAnthropicProvider(config);
- }
-
- // Default: OpenAI-compatible provider (OpenCode Zen — DeepSeek, GLM,
- // Kimi, MiniMax, etc.).
- //
- // `@ai-sdk/[email protected]` handles reasoning round-tripping
- // natively: it reads `{ type: "reasoning", text }` parts from each
- // assistant message's content and emits them as `reasoning_content`
- // on the wire (see node_modules/@ai-sdk/openai-compatible/dist/index.mjs
- // lines 215-216 and 245). Our `toModelMessages` in agent.ts already
- // emits reasoning parts from `ThinkingChunk`s, so no middleware is
- // needed.
- //
- // (The v4-era `normalizeMessages` middleware that lived here was
- // actively breaking DeepSeek: it stripped reasoning parts from
- // content AND wrote them under `providerMetadata` — wrong key in v3
- // prompts, which use `providerOptions`. The result was that
- // reasoning_content never reached the wire and DeepSeek rejected the
- // follow-up turn with "must be passed back".)
- //
- // Debug logging: when DISPATCH_DEBUG_LLM is set, wrap the base fetch
- // so every wire request/response (including SSE chunks) is captured.
- // When disabled, `wrapFetchWithLogging` returns the input unchanged
- // (zero overhead).
- const loggingFetch = wrapFetchWithLogging(globalThis.fetch, {
- tabId: config.tabId,
- modelHint: "opencode-zen",
- }) as unknown as FetchFunction;
-
- const provider = createOpenAICompatible({
- name: "opencode-zen",
- apiKey: config.apiKey,
- baseURL: config.baseURL,
- fetch: loggingFetch,
- });
-
- return (modelId: string) => provider(modelId);
-}
-
-/**
- * Claude OAuth provider. Used by Dispatch's `anthropic` provider keys
- * (claude-pro, claude-max). Uses `authToken` to send `Authorization: Bearer`
- * (natively supported by `@ai-sdk/anthropic` v3.x), and mimics Claude Code CLI
- * request headers so the request bills against the user's Claude subscription.
- *
- * The `anthropic-beta` header is REQUIRED here. `@ai-sdk/anthropic` only emits
- * an `anthropic-beta` header for betas it auto-derives from tool definitions
- * (computer-use, structured-outputs, etc.) — it does NOT add the prompt-caching
- * or oauth betas on its own. Without `prompt-caching-scope-2026-01-05` the API
- * silently ignores every `cache_control` breakpoint we attach to messages,
- * giving a 0% cache hit rate and a massive token burn (see notes/claude-report.md).
- * The SDK folds any `anthropic-beta` it finds on the provider's config headers
- * back into its own beta set (via `getBetasFromHeaders`), so the values here
- * are merged — not overwritten — with any tool-derived betas.
- */
-function createClaudeOAuthProvider(config: ProviderConfig): ModelFactory {
- // Stable per-provider session id — mirrors the Claude Code CLI, which sends
- // the same `X-Claude-Code-Session-Id` across a session's requests.
- const sessionId = randomUUID();
-
- // Wrap the base fetch FIRST so the logging wrapper sees the genuine
- // outgoing HTTP body — i.e. AFTER the OAuth body transform and AFTER the
- // Claude-Code session headers have been stamped on. Order matters: if we
- // wrapped the inner `baseFetch` instead, the logs would show the pre-
- // transform body and miss the session headers, defeating the point of
- // capturing the wire for cache/billing debugging.
- const baseFetch = wrapFetchWithLogging(globalThis.fetch, {
- tabId: config.tabId,
- modelHint: "claude-oauth",
- });
-
- // Custom fetch that (1) restructures the request body into the genuine
- // Claude Code system layout — required for Anthropic to bill correctly and
- // apply the prompt-cache scope (see anthropic-oauth-transform.ts) — and
- // (2) stamps the Claude Code session/request id headers the real CLI sends.
- // Cast through `unknown`: `FetchFunction` is `typeof globalThis.fetch`, whose
- // (Bun) type carries a `preconnect` member a plain wrapper can't satisfy.
- const oauthFetch = (async (
- input: Parameters<FetchFunction>[0],
- init?: Parameters<FetchFunction>[1],
- ) => {
- const nextInit: RequestInit = { ...init };
- if (init?.body != null) {
- nextInit.body = transformClaudeOAuthBody(init.body) ?? init.body;
- }
- const headers = new Headers(init?.headers);
- headers.set("X-Claude-Code-Session-Id", sessionId);
- if (!headers.has("x-client-request-id")) {
- headers.set("x-client-request-id", randomUUID());
- }
- nextInit.headers = headers;
- return baseFetch(input, nextInit);
- }) as unknown as FetchFunction;
-
- const anthropic = createAnthropic({
- baseURL: config.baseURL || "https://api.anthropic.com/v1",
- authToken: config.claudeCredentials?.accessToken ?? config.apiKey,
- fetch: oauthFetch,
- headers: {
- "anthropic-beta": getAnthropicBetas().join(","),
- "anthropic-dangerous-direct-browser-access": "true",
- "x-app": "cli",
- "user-agent": "claude-cli/2.1.112 (external, sdk-cli)",
- },
- });
- return (modelId: string) => anthropic(modelId);
-}
-
-/**
- * Plain-API-key Anthropic-format provider. Used to hit gateways that speak
- * Anthropic's `/messages` protocol with a standard `x-api-key` header — most
- * importantly OpenCode Go's MiniMax and Qwen routes. Unlike the Claude OAuth
- * variant, no `claudeCredentials` are present, no Claude Code mimicry headers
- * are sent, and the API key is passed verbatim through the SDK's default
- * authentication path.
- */
-function createApiKeyAnthropicProvider(config: ProviderConfig): ModelFactory {
- const loggingFetch = wrapFetchWithLogging(globalThis.fetch, {
- tabId: config.tabId,
- modelHint: "opencode-anthropic",
- }) as unknown as FetchFunction;
-
- const anthropic = createAnthropic({
- apiKey: config.apiKey,
- baseURL: config.baseURL || "https://opencode.ai/zen/go/v1",
- fetch: loggingFetch,
- });
-
- return (modelId: string) => anthropic(modelId);
-}
-
-export { prefixToolName, unprefixToolName };
diff --git a/packages/core/src/lsp/client.ts b/packages/core/src/lsp/client.ts
deleted file mode 100644
index da0c916..0000000
--- a/packages/core/src/lsp/client.ts
+++ /dev/null
@@ -1,658 +0,0 @@
-import type { ChildProcessWithoutNullStreams } from "node:child_process";
-import { readFile } from "node:fs/promises";
-import { extname, isAbsolute, resolve } from "node:path";
-import { fileURLToPath, pathToFileURL } from "node:url";
-import {
- createMessageConnection,
- type MessageConnection,
- StreamMessageReader,
- StreamMessageWriter,
-} from "vscode-jsonrpc/node";
-import type { Diagnostic } from "vscode-languageserver-types";
-import { languageIdForExtension } from "./language.js";
-
-export type { Diagnostic } from "vscode-languageserver-types";
-
-// ─── Timing constants (mirrors opencode) ─────────────────────────
-const DIAGNOSTICS_DEBOUNCE_MS = 150;
-const DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS = 5_000;
-const DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS = 10_000;
-const DIAGNOSTICS_REQUEST_TIMEOUT_MS = 3_000;
-const INITIALIZE_TIMEOUT_MS = 45_000;
-
-// ─── LSP spec constants ──────────────────────────────────────────
-const FILE_CHANGE_CREATED = 1;
-const FILE_CHANGE_CHANGED = 2;
-const TEXT_DOCUMENT_SYNC_INCREMENTAL = 2;
-
-/**
- * A live spawned language-server process plus the `initializationOptions` to
- * hand it. Produced by the server-spawning layer (`server.ts`) and consumed by
- * `createLspClient`.
- */
-export interface LspServerHandle {
- process: ChildProcessWithoutNullStreams;
- initialization?: Record<string, unknown>;
-}
-
-interface ServerCapabilities {
- textDocumentSync?: number | { change?: number };
- diagnosticProvider?: unknown;
- [key: string]: unknown;
-}
-
-interface DiagnosticRequestResult {
- handled: boolean;
- matched: boolean;
- byFile: Map<string, Diagnostic[]>;
-}
-
-interface CapabilityRegistration {
- id: string;
- method: string;
- registerOptions?: {
- identifier?: string;
- workspaceDiagnostics?: boolean;
- };
-}
-
-type DocumentDiagnosticReport = {
- items?: Diagnostic[];
- relatedDocuments?: Record<string, DocumentDiagnosticReport>;
-};
-
-type WorkspaceDiagnosticReport = {
- items?: { uri?: string; items?: Diagnostic[] }[];
-};
-
-/** Public shape of a connected LSP client. */
-export interface LspClient {
- readonly serverID: string;
- readonly root: string;
- readonly connection: MessageConnection;
- /**
- * Open (or re-sync) a file with the server. Returns the document version
- * sent — pass it to `waitForDiagnostics` to wait for diagnostics matching
- * this exact sync.
- */
- notifyOpen(path: string): Promise<number>;
- /** Snapshot of all known diagnostics keyed by absolute file path. */
- readonly diagnostics: Map<string, Diagnostic[]>;
- /** Wait until diagnostics for `path` settle (push and/or pull). */
- waitForDiagnostics(request: {
- path: string;
- version: number;
- mode?: "document" | "full";
- after?: number;
- }): Promise<void>;
- /** Generic LSP request passthrough (hover, definition, references, …). */
- request<T = unknown>(method: string, params: unknown): Promise<T | null>;
- /** Shut the connection and child process down. */
- shutdown(): Promise<void>;
-}
-
-function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
- return new Promise<T>((resolvePromise, reject) => {
- const timer = setTimeout(() => reject(new Error(`LSP request timed out after ${ms}ms`)), ms);
- promise.then(
- (value) => {
- clearTimeout(timer);
- resolvePromise(value);
- },
- (err) => {
- clearTimeout(timer);
- reject(err);
- },
- );
- });
-}
-
-function getFilePath(uri: string): string | undefined {
- if (!uri.startsWith("file://")) return undefined;
- return fileURLToPath(uri);
-}
-
-function getSyncKind(capabilities?: ServerCapabilities): number | undefined {
- if (!capabilities) return undefined;
- const sync = capabilities.textDocumentSync;
- if (typeof sync === "number") return sync;
- return sync?.change;
-}
-
-function endPosition(text: string) {
- const lines = text.split(/\r\n|\r|\n/);
- return { line: lines.length - 1, character: lines.at(-1)?.length ?? 0 };
-}
-
-function dedupeDiagnostics(items: Diagnostic[]): Diagnostic[] {
- const seen = new Set<string>();
- return items.filter((item) => {
- const key = JSON.stringify({
- code: item.code,
- severity: item.severity,
- message: item.message,
- source: item.source,
- range: item.range,
- });
- if (seen.has(key)) return false;
- seen.add(key);
- return true;
- });
-}
-
-function configurationValue(settings: unknown, section?: string): unknown {
- if (!section) return settings ?? null;
- const result = section.split(".").reduce<unknown>((acc, key) => {
- if (!acc || typeof acc !== "object" || !(key in acc)) return undefined;
- return (acc as Record<string, unknown>)[key];
- }, settings);
- return result ?? null;
-}
-
-/**
- * Create and initialize an LSP client over a spawned server's stdio.
- *
- * Performs the full `initialize`/`initialized` handshake (with a 45s timeout),
- * wires push (`textDocument/publishDiagnostics`) and pull
- * (`textDocument/diagnostic`, `workspace/diagnostic`) diagnostics, answers the
- * `workspace/configuration`, `workspaceFolders`, and capability-registration
- * requests servers commonly make, and returns a small client surface used by
- * the manager and tools. Plain-TypeScript port of opencode's `lsp/client.ts`.
- */
-export async function createLspClient(input: {
- serverID: string;
- server: LspServerHandle;
- root: string;
- directory: string;
-}): Promise<LspClient> {
- const { serverID, server, root, directory } = input;
-
- const connection = createMessageConnection(
- new StreamMessageReader(server.process.stdout),
- new StreamMessageWriter(server.process.stdin),
- );
-
- // Server stderr is routine for many tools (luau-lsp logs sourcemap status
- // there). Keep it quiet unless debugging.
- server.process.stderr?.on("data", () => {
- /* swallowed — see opencode: stderr is mostly informational */
- });
-
- // ─── Connection state ───
- const pushDiagnostics = new Map<string, Diagnostic[]>();
- const pullDiagnostics = new Map<string, Diagnostic[]>();
- const published = new Map<string, { at: number; version?: number }>();
- const diagnosticRegistrations = new Map<string, CapabilityRegistration>();
- const registrationListeners = new Set<() => void>();
- const diagnosticListeners = new Set<(input: { path: string; serverID: string }) => void>();
- const files: Record<string, { version: number; text: string }> = {};
-
- const mergedDiagnostics = (filePath: string) =>
- dedupeDiagnostics([
- ...(pushDiagnostics.get(filePath) ?? []),
- ...(pullDiagnostics.get(filePath) ?? []),
- ]);
- const updatePushDiagnostics = (filePath: string, next: Diagnostic[]) => {
- pushDiagnostics.set(filePath, next);
- for (const listener of diagnosticListeners) listener({ path: filePath, serverID });
- };
- const updatePullDiagnostics = (filePath: string, next: Diagnostic[]) => {
- pullDiagnostics.set(filePath, next);
- };
- const emitRegistrationChange = () => {
- for (const listener of [...registrationListeners]) listener();
- };
-
- // ─── Notification / request handlers ───
- connection.onNotification(
- "textDocument/publishDiagnostics",
- (params: { uri: string; diagnostics: Diagnostic[]; version?: number }) => {
- const filePath = getFilePath(params.uri);
- if (!filePath) return;
- published.set(filePath, {
- at: Date.now(),
- version: typeof params.version === "number" ? params.version : undefined,
- });
- updatePushDiagnostics(filePath, params.diagnostics);
- },
- );
- connection.onRequest("window/workDoneProgress/create", () => null);
- connection.onRequest("workspace/configuration", (params: { items?: { section?: string }[] }) => {
- const items = params.items ?? [];
- return items.map((item) => configurationValue(server.initialization, item.section));
- });
- connection.onRequest(
- "client/registerCapability",
- (params: { registrations?: CapabilityRegistration[] }) => {
- const registrations = params.registrations ?? [];
- let changed = false;
- for (const registration of registrations) {
- if (registration.method !== "textDocument/diagnostic") continue;
- diagnosticRegistrations.set(registration.id, registration);
- changed = true;
- }
- if (changed) emitRegistrationChange();
- return null;
- },
- );
- connection.onRequest(
- "client/unregisterCapability",
- (params: { unregisterations?: { id: string; method: string }[] }) => {
- const registrations = params.unregisterations ?? [];
- let changed = false;
- for (const registration of registrations) {
- if (registration.method !== "textDocument/diagnostic") continue;
- diagnosticRegistrations.delete(registration.id);
- changed = true;
- }
- if (changed) emitRegistrationChange();
- return null;
- },
- );
- connection.onRequest("workspace/workspaceFolders", () => [
- { name: "workspace", uri: pathToFileURL(root).href },
- ]);
- connection.onRequest("workspace/diagnostic/refresh", () => null);
- connection.listen();
-
- // ─── Initialize handshake ───
- const initialized = await withTimeout(
- connection.sendRequest<{ capabilities?: ServerCapabilities }>("initialize", {
- rootUri: pathToFileURL(root).href,
- processId: server.process.pid ?? null,
- workspaceFolders: [{ name: "workspace", uri: pathToFileURL(root).href }],
- initializationOptions: { ...server.initialization },
- capabilities: {
- window: { workDoneProgress: true },
- workspace: {
- configuration: true,
- didChangeWatchedFiles: { dynamicRegistration: true },
- diagnostics: { refreshSupport: false },
- },
- textDocument: {
- synchronization: { didOpen: true, didChange: true },
- diagnostic: { dynamicRegistration: true, relatedDocumentSupport: true },
- publishDiagnostics: { versionSupport: false },
- },
- },
- }),
- INITIALIZE_TIMEOUT_MS,
- );
-
- const syncKind = getSyncKind(initialized.capabilities);
- const hasStaticPullDiagnostics = Boolean(initialized.capabilities?.diagnosticProvider);
-
- await connection.sendNotification("initialized", {});
- if (server.initialization) {
- await connection.sendNotification("workspace/didChangeConfiguration", {
- settings: server.initialization,
- });
- }
-
- // ─── Pull-diagnostics helpers ───
- const mergeResults = (filePath: string, results: DiagnosticRequestResult[]) => {
- const handled = results.some((r) => r.handled);
- const matched = results.some((r) => r.matched);
- if (!handled) return { handled: false, matched: false };
-
- const merged = new Map<string, Diagnostic[]>();
- for (const result of results) {
- for (const [target, items] of result.byFile.entries()) {
- merged.set(target, (merged.get(target) ?? []).concat(items));
- }
- }
- if (matched && !merged.has(filePath)) merged.set(filePath, []);
- for (const [target, items] of merged.entries()) {
- updatePullDiagnostics(target, dedupeDiagnostics(items));
- }
- return { handled, matched };
- };
-
- async function requestDiagnosticReport(
- filePath: string,
- identifier?: string,
- ): Promise<DiagnosticRequestResult> {
- const report = await withTimeout(
- connection.sendRequest<DocumentDiagnosticReport | null>("textDocument/diagnostic", {
- ...(identifier ? { identifier } : {}),
- textDocument: { uri: pathToFileURL(filePath).href },
- }),
- DIAGNOSTICS_REQUEST_TIMEOUT_MS,
- ).catch(() => null);
- const empty: DiagnosticRequestResult = {
- handled: false,
- matched: false,
- byFile: new Map(),
- };
- if (!report) return empty;
-
- const byFile = new Map<string, Diagnostic[]>();
- const push = (target: string, items: Diagnostic[]) => {
- byFile.set(target, (byFile.get(target) ?? []).concat(items));
- };
- let handled = false;
- let matched = false;
- if (Array.isArray(report.items)) {
- push(filePath, report.items);
- handled = true;
- matched = true;
- }
- for (const [uri, related] of Object.entries(report.relatedDocuments ?? {})) {
- const relatedPath = getFilePath(uri);
- if (!relatedPath || !Array.isArray(related.items)) continue;
- push(relatedPath, related.items);
- handled = true;
- matched = matched || relatedPath === filePath;
- }
- return { handled, matched, byFile };
- }
-
- async function requestWorkspaceDiagnosticReport(
- filePath: string,
- identifier?: string,
- ): Promise<DiagnosticRequestResult> {
- const report = await withTimeout(
- connection.sendRequest<WorkspaceDiagnosticReport | null>("workspace/diagnostic", {
- ...(identifier ? { identifier } : {}),
- previousResultIds: [],
- }),
- DIAGNOSTICS_REQUEST_TIMEOUT_MS,
- ).catch(() => null);
- if (!report) return { handled: false, matched: false, byFile: new Map() };
-
- const byFile = new Map<string, Diagnostic[]>();
- let matched = false;
- for (const item of report.items ?? []) {
- const relatedPath = item.uri ? getFilePath(item.uri) : undefined;
- if (!relatedPath || !Array.isArray(item.items)) continue;
- byFile.set(relatedPath, (byFile.get(relatedPath) ?? []).concat(item.items));
- matched = matched || relatedPath === filePath;
- }
- return { handled: true, matched, byFile };
- }
-
- function documentPullState() {
- const documentRegistrations = [...diagnosticRegistrations.values()].filter(
- (r) => r.registerOptions?.workspaceDiagnostics !== true,
- );
- return {
- documentIdentifiers: [
- ...new Set(documentRegistrations.flatMap((r) => r.registerOptions?.identifier ?? [])),
- ],
- supported: hasStaticPullDiagnostics || documentRegistrations.length > 0,
- };
- }
-
- function workspacePullState() {
- const workspaceRegistrations = [...diagnosticRegistrations.values()].filter(
- (r) => r.registerOptions?.workspaceDiagnostics === true,
- );
- return {
- workspaceIdentifiers: [
- ...new Set(workspaceRegistrations.flatMap((r) => r.registerOptions?.identifier ?? [])),
- ],
- supported: workspaceRegistrations.length > 0,
- };
- }
-
- const hasCurrentFileDiagnostics = (filePath: string, results: DiagnosticRequestResult[]) =>
- results.some((r) => (r.byFile.get(filePath)?.length ?? 0) > 0);
-
- async function requestDiagnostics(
- filePath: string,
- requests: Promise<DiagnosticRequestResult>[],
- done: (results: DiagnosticRequestResult[]) => boolean,
- ) {
- if (!requests.length) return { handled: false, matched: false };
- const results: DiagnosticRequestResult[] = [];
- return new Promise<{ handled: boolean; matched: boolean }>((resolvePromise) => {
- let pending = requests.length;
- let resolved = false;
- const finish = (merged: { handled: boolean; matched: boolean }, force = false) => {
- if (resolved) return;
- if (!force && !done(results)) return;
- resolved = true;
- resolvePromise(merged);
- };
- for (const request of requests) {
- request.then((result) => {
- results.push(result);
- pending -= 1;
- const merged = mergeResults(filePath, results);
- finish(merged);
- if (pending === 0) finish(merged, true);
- });
- }
- });
- }
-
- async function requestDocumentDiagnostics(filePath: string) {
- const state = documentPullState();
- if (!state.supported) return { handled: false, matched: false };
- return requestDiagnostics(
- filePath,
- [
- requestDiagnosticReport(filePath),
- ...state.documentIdentifiers.map((id) => requestDiagnosticReport(filePath, id)),
- ],
- (results) => hasCurrentFileDiagnostics(filePath, results),
- );
- }
-
- async function requestFullDiagnostics(filePath: string) {
- const documentState = documentPullState();
- const workspaceState = workspacePullState();
- if (!documentState.supported && !workspaceState.supported) {
- return { handled: false, matched: false };
- }
- return mergeResults(
- filePath,
- await Promise.all([
- ...(documentState.supported ? [requestDiagnosticReport(filePath)] : []),
- ...documentState.documentIdentifiers.map((id) => requestDiagnosticReport(filePath, id)),
- ...(workspaceState.supported ? [requestWorkspaceDiagnosticReport(filePath)] : []),
- ...workspaceState.workspaceIdentifiers.map((id) =>
- requestWorkspaceDiagnosticReport(filePath, id),
- ),
- ]),
- );
- }
-
- function waitForRegistrationChange(timeout: number) {
- if (timeout <= 0) return Promise.resolve(false);
- return new Promise<boolean>((resolvePromise) => {
- let finished = false;
- let timer: ReturnType<typeof setTimeout> | undefined;
- const finish = (result: boolean) => {
- if (finished) return;
- finished = true;
- if (timer) clearTimeout(timer);
- registrationListeners.delete(listener);
- resolvePromise(result);
- };
- const listener = () => finish(true);
- registrationListeners.add(listener);
- timer = setTimeout(() => finish(false), timeout);
- });
- }
-
- function waitForFreshPush(request: {
- path: string;
- version: number;
- after: number;
- timeout: number;
- }) {
- if (request.timeout <= 0) return Promise.resolve(false);
- return new Promise<boolean>((resolvePromise) => {
- let finished = false;
- let debounceTimer: ReturnType<typeof setTimeout> | undefined;
- let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
- let unsub: (() => void) | undefined;
- const finish = (result: boolean) => {
- if (finished) return;
- finished = true;
- if (debounceTimer) clearTimeout(debounceTimer);
- if (timeoutTimer) clearTimeout(timeoutTimer);
- unsub?.();
- resolvePromise(result);
- };
- const schedule = () => {
- const hit = published.get(request.path);
- if (!hit) return;
- if (typeof hit.version === "number" && hit.version !== request.version) return;
- if (hit.at < request.after && hit.version !== request.version) return;
- if (debounceTimer) clearTimeout(debounceTimer);
- debounceTimer = setTimeout(
- () => finish(true),
- Math.max(0, DIAGNOSTICS_DEBOUNCE_MS - (Date.now() - hit.at)),
- );
- };
- timeoutTimer = setTimeout(() => finish(false), request.timeout);
- const listener = (event: { path: string; serverID: string }) => {
- if (event.path !== request.path || event.serverID !== serverID) return;
- schedule();
- };
- diagnosticListeners.add(listener);
- unsub = () => diagnosticListeners.delete(listener);
- schedule();
- });
- }
-
- async function waitForDocumentDiagnostics(request: {
- path: string;
- version: number;
- after?: number;
- }) {
- const startedAt = request.after ?? Date.now();
- const pushWait = waitForFreshPush({
- path: request.path,
- version: request.version,
- after: startedAt,
- timeout: DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS,
- });
- while (Date.now() - startedAt < DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS) {
- const result = await requestDocumentDiagnostics(request.path);
- if (result.matched) return;
- const remaining = DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS - (Date.now() - startedAt);
- if (remaining <= 0) return;
- const next = await Promise.race([
- pushWait.then((ready) => (ready ? "push" : "timeout")),
- waitForRegistrationChange(remaining).then((c) => (c ? "registration" : "timeout")),
- ]);
- if (next !== "registration") return;
- }
- }
-
- async function waitForFullDiagnostics(request: {
- path: string;
- version: number;
- after?: number;
- }) {
- const startedAt = request.after ?? Date.now();
- const pushWait = waitForFreshPush({
- path: request.path,
- version: request.version,
- after: startedAt,
- timeout: DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS,
- });
- while (Date.now() - startedAt < DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS) {
- const result = await requestFullDiagnostics(request.path);
- if (result.handled || result.matched) return;
- const remaining = DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS - (Date.now() - startedAt);
- if (remaining <= 0) return;
- const next = await Promise.race([
- pushWait.then((ready) => (ready ? "push" : "timeout")),
- waitForRegistrationChange(remaining).then((c) => (c ? "registration" : "timeout")),
- ]);
- if (next !== "registration") return;
- }
- }
-
- const normalize = (p: string) => (isAbsolute(p) ? p : resolve(directory, p));
-
- // ─── Public surface ───
- const client: LspClient = {
- serverID,
- root,
- connection,
- async notifyOpen(path: string) {
- const filePath = normalize(path);
- const text = await readFile(filePath, "utf8");
- const languageId = languageIdForExtension(extname(filePath));
- const uri = pathToFileURL(filePath).href;
- const document = files[filePath];
-
- if (document !== undefined) {
- await connection.sendNotification("workspace/didChangeWatchedFiles", {
- changes: [{ uri, type: FILE_CHANGE_CHANGED }],
- });
- const next = document.version + 1;
- files[filePath] = { version: next, text };
- await connection.sendNotification("textDocument/didChange", {
- textDocument: { uri, version: next },
- contentChanges:
- syncKind === TEXT_DOCUMENT_SYNC_INCREMENTAL
- ? [
- {
- range: {
- start: { line: 0, character: 0 },
- end: endPosition(document.text),
- },
- text,
- },
- ]
- : [{ text }],
- });
- return next;
- }
-
- await connection.sendNotification("workspace/didChangeWatchedFiles", {
- changes: [{ uri, type: FILE_CHANGE_CREATED }],
- });
- pushDiagnostics.delete(filePath);
- pullDiagnostics.delete(filePath);
- await connection.sendNotification("textDocument/didOpen", {
- textDocument: { uri, languageId, version: 0, text },
- });
- files[filePath] = { version: 0, text };
- return 0;
- },
- get diagnostics() {
- const result = new Map<string, Diagnostic[]>();
- for (const key of new Set([...pushDiagnostics.keys(), ...pullDiagnostics.keys()])) {
- result.set(key, mergedDiagnostics(key));
- }
- return result;
- },
- async waitForDiagnostics(request) {
- const normalizedPath = normalize(request.path);
- if (request.mode === "document") {
- await waitForDocumentDiagnostics({
- path: normalizedPath,
- version: request.version,
- after: request.after,
- });
- return;
- }
- await waitForFullDiagnostics({
- path: normalizedPath,
- version: request.version,
- after: request.after,
- });
- },
- async request<T = unknown>(method: string, params: unknown): Promise<T | null> {
- return connection.sendRequest<T>(method, params).catch(() => null);
- },
- async shutdown() {
- try {
- connection.end();
- connection.dispose();
- } catch {
- /* connection may already be closed */
- }
- server.process.kill();
- },
- };
-
- return client;
-}
diff --git a/packages/core/src/lsp/diagnostic.ts b/packages/core/src/lsp/diagnostic.ts
deleted file mode 100644
index 1ad4d0f..0000000
--- a/packages/core/src/lsp/diagnostic.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import type { Diagnostic } from "vscode-languageserver-types";
-
-/**
- * Diagnostic formatting helpers. Ported from opencode's `lsp/diagnostic.ts`.
- *
- * LSP positions are 0-based on the wire; we render them 1-based (editor-style)
- * so they line up with what `read_file` shows and what editors report.
- */
-
-/** Max diagnostics rendered per file before truncating with a "… and N more". */
-const MAX_PER_FILE = 20;
-
-const SEVERITY_LABEL: Record<number, string> = {
- 1: "ERROR",
- 2: "WARN",
- 3: "INFO",
- 4: "HINT",
-};
-
-/** Render a single diagnostic as `SEVERITY [line:col] message` (1-based). */
-export function pretty(diagnostic: Diagnostic): string {
- const severity = SEVERITY_LABEL[diagnostic.severity ?? 1] ?? "ERROR";
- const line = diagnostic.range.start.line + 1;
- const col = diagnostic.range.start.character + 1;
- return `${severity} [${line}:${col}] ${diagnostic.message}`;
-}
-
-/**
- * Build a `<diagnostics file="…">` block for a file's ERROR-severity
- * diagnostics, or `""` when there are none. Errors only — warnings/info/hints
- * are intentionally omitted so the model is nudged toward the things that
- * actually break the build (matching opencode's behavior).
- */
-export function report(file: string, issues: Diagnostic[]): string {
- const errors = issues.filter((item) => item.severity === 1);
- if (errors.length === 0) return "";
- const limited = errors.slice(0, MAX_PER_FILE);
- const more = errors.length - MAX_PER_FILE;
- const suffix = more > 0 ? `\n... and ${more} more` : "";
- return `<diagnostics file="${file}">\n${limited.map(pretty).join("\n")}${suffix}\n</diagnostics>`;
-}
diff --git a/packages/core/src/lsp/index.ts b/packages/core/src/lsp/index.ts
deleted file mode 100644
index fd43c2f..0000000
--- a/packages/core/src/lsp/index.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-// LSP (Language Server Protocol) integration.
-//
-// Config-driven only: servers are declared in `dispatch.toml`'s `[lsp.<id>]`
-// block (see `LspServerConfig` in `../types`). There is no builtin server
-// registry and no auto-download. The primary model-facing surface is
-// diagnostics-on-write (the host passes a write hook that calls `touchFile` +
-// `report`); an on-demand `lsp` tool exposes hover/definition/references too.
-
-export {
- createLspClient,
- type Diagnostic,
- type LspClient,
- type LspServerHandle,
-} from "./client.js";
-export { pretty, report } from "./diagnostic.js";
-export { LANGUAGE_EXTENSIONS, languageIdForExtension } from "./language.js";
-export { LspManager } from "./manager.js";
-export { type ResolvedLspServer, resolveServersFromConfig } from "./server.js";
diff --git a/packages/core/src/lsp/language.ts b/packages/core/src/lsp/language.ts
deleted file mode 100644
index 3e9fe68..0000000
--- a/packages/core/src/lsp/language.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-/**
- * File-extension → LSP `languageId` map.
- *
- * The LSP `textDocument/didOpen` notification carries a `languageId` string
- * that tells the server how to parse the document. This table is a trimmed
- * port of opencode's `lsp/language.ts`, with one critical addition for this
- * project: `.luau` → `"luau"`. Roblox Luau sources use the `.luau` extension,
- * which standard Lua tooling does not recognise — luau-lsp expects the
- * `"luau"` languageId.
- *
- * Extensions are looked up with their leading dot (e.g. `".luau"`). Unknown
- * extensions fall back to `"plaintext"` at the call site.
- */
-export const LANGUAGE_EXTENSIONS: Record<string, string> = {
- // Luau (Roblox) — the reason this module exists. Keep first for visibility.
- ".luau": "luau",
- ".lua": "lua",
- // A pragmatic subset of common languages, mirroring opencode's table so a
- // user can point an arbitrary LSP server at this codebase and have the
- // right languageId reported.
- ".c": "c",
- ".cpp": "cpp",
- ".cc": "cpp",
- ".cxx": "cpp",
- ".h": "c",
- ".hpp": "cpp",
- ".cs": "csharp",
- ".css": "css",
- ".dart": "dart",
- ".go": "go",
- ".html": "html",
- ".htm": "html",
- ".java": "java",
- ".js": "javascript",
- ".jsx": "javascriptreact",
- ".json": "json",
- ".jsonc": "jsonc",
- ".kt": "kotlin",
- ".kts": "kotlin",
- ".md": "markdown",
- ".markdown": "markdown",
- ".php": "php",
- ".py": "python",
- ".rb": "ruby",
- ".rs": "rust",
- ".scss": "scss",
- ".sass": "sass",
- ".sh": "shellscript",
- ".bash": "shellscript",
- ".zsh": "shellscript",
- ".sql": "sql",
- ".svelte": "svelte",
- ".swift": "swift",
- ".toml": "toml",
- ".ts": "typescript",
- ".tsx": "typescriptreact",
- ".mts": "typescript",
- ".cts": "typescript",
- ".vue": "vue",
- ".xml": "xml",
- ".yaml": "yaml",
- ".yml": "yaml",
- ".zig": "zig",
-};
-
-/**
- * Resolve the LSP `languageId` for a file path's extension, falling back to
- * `"plaintext"` when the extension is unknown.
- */
-export function languageIdForExtension(extension: string): string {
- return LANGUAGE_EXTENSIONS[extension] ?? "plaintext";
-}
diff --git a/packages/core/src/lsp/manager.ts b/packages/core/src/lsp/manager.ts
deleted file mode 100644
index db8b68e..0000000
--- a/packages/core/src/lsp/manager.ts
+++ /dev/null
@@ -1,220 +0,0 @@
-import { extname } from "node:path";
-import { createLspClient, type Diagnostic, type LspClient } from "./client.js";
-import type { ResolvedLspServer } from "./server.js";
-
-/**
- * Process-wide owner of LSP client lifecycles.
- *
- * Clients are keyed by `root + serverID` and spawned lazily on the first file
- * that matches a server's extensions, then reused. Concurrent spawns for the
- * same key are de-duplicated via an in-flight map, and servers that fail to
- * start are remembered in `broken` so we don't spawn-spam. Modeled on
- * opencode's `lsp/lsp.ts` `getClients` flow, minus the Effect machinery.
- *
- * The manager is config-agnostic: callers resolve `ResolvedLspServer[]` from a
- * tab's working-directory config (`resolveServersFromConfig`) and pass them in
- * alongside the `root`. This keeps per-working-directory config out of the
- * manager while letting it own all the long-lived processes for the process.
- */
-export class LspManager {
- private clients = new Map<string, LspClient>();
- private spawning = new Map<string, Promise<LspClient | undefined>>();
- private broken = new Set<string>();
-
- private key(root: string, serverID: string): string {
- return `${root}\u0000${serverID}`;
- }
-
- private serversForFile(file: string, servers: ResolvedLspServer[]): ResolvedLspServer[] {
- const extension = extname(file) || file;
- return servers.filter(
- (server) => server.extensions.length === 0 || server.extensions.includes(extension),
- );
- }
-
- /**
- * True if any provided server is configured to attach to this file's
- * extension (regardless of whether it has spawned yet). Used to decide
- * whether an LSP operation is even applicable to a file.
- */
- hasServerForFile(file: string, servers: ResolvedLspServer[]): boolean {
- return this.serversForFile(file, servers).length > 0;
- }
-
- /**
- * Get (spawning if needed) all clients that should attach to `file` at
- * `root`. Spawn failures are swallowed (logged via `broken`) and simply
- * yield fewer clients — callers degrade gracefully to "no diagnostics".
- */
- async getClients(input: {
- file: string;
- root: string;
- servers: ResolvedLspServer[];
- }): Promise<LspClient[]> {
- const { file, root, servers } = input;
- const matching = this.serversForFile(file, servers);
- const result: LspClient[] = [];
-
- for (const server of matching) {
- const key = this.key(root, server.id);
- if (this.broken.has(key)) continue;
-
- const existing = this.clients.get(key);
- if (existing) {
- result.push(existing);
- continue;
- }
-
- const inflight = this.spawning.get(key);
- if (inflight) {
- const client = await inflight;
- if (client) result.push(client);
- continue;
- }
-
- const task = this.spawn(server, root, key);
- this.spawning.set(key, task);
- task.finally(() => {
- if (this.spawning.get(key) === task) this.spawning.delete(key);
- });
- const client = await task;
- if (client) result.push(client);
- }
-
- return result;
- }
-
- private async spawn(
- server: ResolvedLspServer,
- root: string,
- key: string,
- ): Promise<LspClient | undefined> {
- let handle: ReturnType<ResolvedLspServer["spawn"]>;
- try {
- handle = server.spawn(root);
- } catch (err) {
- this.broken.add(key);
- console.warn(
- `dispatch: failed to spawn LSP server "${server.id}": ${err instanceof Error ? err.message : String(err)}`,
- );
- return undefined;
- }
-
- // A spawn that fails asynchronously (e.g. ENOENT — binary not on PATH)
- // emits `error` on the child process; mark broken so we don't retry it.
- handle.process.on("error", (err) => {
- this.broken.add(key);
- console.warn(`dispatch: LSP server "${server.id}" process error: ${err.message}`);
- });
-
- try {
- const client = await createLspClient({
- serverID: server.id,
- server: handle,
- root,
- directory: root,
- });
- // A racing caller may have created the same client; prefer the
- // existing one and discard ours.
- const existing = this.clients.get(key);
- if (existing) {
- await client.shutdown();
- return existing;
- }
- this.clients.set(key, client);
- return client;
- } catch (err) {
- this.broken.add(key);
- try {
- handle.process.kill();
- } catch {
- /* already dead */
- }
- console.warn(
- `dispatch: failed to initialize LSP client "${server.id}": ${err instanceof Error ? err.message : String(err)}`,
- );
- return undefined;
- }
- }
-
- /**
- * Open/sync a file with its clients and (optionally) wait for diagnostics
- * to settle. `mode: "document"` waits for the file's own diagnostics;
- * `"full"` also waits on workspace diagnostics; omitted just syncs.
- */
- async touchFile(input: {
- file: string;
- root: string;
- servers: ResolvedLspServer[];
- mode?: "document" | "full";
- }): Promise<void> {
- const clients = await this.getClients(input);
- await Promise.all(
- clients.map(async (client) => {
- const after = Date.now();
- const version = await client.notifyOpen(input.file);
- if (!input.mode) return;
- await client.waitForDiagnostics({
- path: input.file,
- version,
- mode: input.mode,
- after,
- });
- }),
- ).catch((err) => {
- console.warn(
- `dispatch: failed to touch file for LSP: ${err instanceof Error ? err.message : String(err)}`,
- );
- });
- }
-
- /**
- * Merged diagnostics for a single file across all of its clients, keyed by
- * absolute file path. Includes related-file diagnostics a client surfaced
- * (e.g. workspace pulls), so the result map may contain more than `file`.
- */
- getDiagnostics(input: {
- root: string;
- servers: ResolvedLspServer[];
- file: string;
- }): Record<string, Diagnostic[]> {
- const results: Record<string, Diagnostic[]> = {};
- const matching = this.serversForFile(input.file, input.servers);
- for (const server of matching) {
- const client = this.clients.get(this.key(input.root, server.id));
- if (!client) continue;
- for (const [path, diags] of client.diagnostics.entries()) {
- results[path] = (results[path] ?? []).concat(diags);
- }
- }
- return results;
- }
-
- /**
- * Run a positional LSP request (hover/definition/references/etc.) against
- * every client for the file and flatten the (non-null) results. `line`/
- * `character` are 0-based here — the caller converts from editor 1-based.
- */
- async request(input: {
- file: string;
- root: string;
- servers: ResolvedLspServer[];
- method: string;
- params: Record<string, unknown>;
- }): Promise<unknown[]> {
- const clients = await this.getClients(input);
- const results = await Promise.all(
- clients.map((client) => client.request(input.method, input.params)),
- );
- return results.filter((r) => r !== null && r !== undefined);
- }
-
- /** Shut down every live client and clear all state. */
- async shutdownAll(): Promise<void> {
- const clients = [...this.clients.values()];
- this.clients.clear();
- this.spawning.clear();
- this.broken.clear();
- await Promise.all(clients.map((client) => client.shutdown().catch(() => {})));
- }
-}
diff --git a/packages/core/src/lsp/server.ts b/packages/core/src/lsp/server.ts
deleted file mode 100644
index 1fb002e..0000000
--- a/packages/core/src/lsp/server.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
-import type { LspServerConfig } from "../types/index.js";
-import type { LspServerHandle } from "./client.js";
-
-/**
- * A resolved, ready-to-spawn LSP server derived from a `dispatch.toml`
- * `[lsp.<id>]` entry. Config-driven only — dispatch ships no builtin server
- * registry and performs no auto-download (unlike opencode). The declared
- * executable (`command[0]`) must already be on PATH.
- */
-export interface ResolvedLspServer {
- id: string;
- /** Extensions (with leading dot) this server attaches to, e.g. `".luau"`. */
- extensions: string[];
- /** Launch the server over stdio rooted at `root`. */
- spawn(root: string): LspServerHandle;
-}
-
-/**
- * Spawn a child process for an LSP server over stdio. Inherits `process.env`
- * (so a PATH-resident `rojo` is visible to luau-lsp's sourcemap autogenerate)
- * and merges any `env` from the server config on top.
- */
-function spawnServer(
- command: string[],
- cwd: string,
- env: Record<string, string> | undefined,
- initialization: Record<string, unknown> | undefined,
-): LspServerHandle {
- const [cmd, ...args] = command;
- if (!cmd) throw new Error("LSP server command is empty");
- const proc = spawn(cmd, args, {
- cwd,
- env: { ...process.env, ...env },
- stdio: ["pipe", "pipe", "pipe"],
- }) as ChildProcessWithoutNullStreams;
- return {
- process: proc,
- ...(initialization ? { initialization } : {}),
- };
-}
-
-/**
- * Turn the parsed `dispatch.toml` `lsp` block into a list of spawnable
- * servers. Disabled entries are dropped. Entries with no `command`/`extensions`
- * are skipped defensively (the config validator already enforces these, but we
- * guard here too so a hand-built config object can't crash the manager).
- */
-export function resolveServersFromConfig(
- lsp: Record<string, LspServerConfig> | undefined,
-): ResolvedLspServer[] {
- if (!lsp) return [];
- const servers: ResolvedLspServer[] = [];
- for (const [id, entry] of Object.entries(lsp)) {
- if (entry.disabled) continue;
- if (!entry.command || entry.command.length === 0) continue;
- if (!entry.extensions || entry.extensions.length === 0) continue;
- const command = entry.command;
- const env = entry.env;
- const initialization = entry.initialization;
- servers.push({
- id,
- extensions: entry.extensions,
- spawn: (root: string) => spawnServer(command, root, env, initialization),
- });
- }
- return servers;
-}
diff --git a/packages/core/src/models/attachments.ts b/packages/core/src/models/attachments.ts
deleted file mode 100644
index 5c98db4..0000000
--- a/packages/core/src/models/attachments.ts
+++ /dev/null
@@ -1,151 +0,0 @@
-// Validation + limits for multimodal user attachments (images / PDFs).
-//
-// Kept dependency-free (no DB / `bun:sqlite` import) so both the API layer
-// (`/chat` request validation) and any future caller can share the exact same
-// allowlist and size/count ceilings. The limits mirror Anthropic's documented
-// vision/PDF API constraints (the only image-capable providers Dispatch maps),
-// so a request that passes here won't be rejected by the provider for size.
-
-import type { UserAttachmentPart, UserContentPart } from "../types/index.js";
-
-/** Accepted image media types. */
-export const ACCEPTED_IMAGE_MEDIA_TYPES = [
- "image/png",
- "image/jpeg",
- "image/webp",
- "image/gif",
-] as const;
-
-/** Accepted document media types. */
-export const ACCEPTED_PDF_MEDIA_TYPE = "application/pdf";
-
-/** Every media type we accept as an attachment. */
-export const ACCEPTED_ATTACHMENT_MEDIA_TYPES = [
- ...ACCEPTED_IMAGE_MEDIA_TYPES,
- ACCEPTED_PDF_MEDIA_TYPE,
-] as const;
-
-/** Per-image byte ceiling (Anthropic: 5 MB/image). */
-export const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
-
-/** Per-PDF byte ceiling (Anthropic: 32 MB/PDF). */
-export const MAX_PDF_BYTES = 32 * 1024 * 1024;
-
-/** Max attachments per message (Anthropic: 20 images/request). */
-export const MAX_ATTACHMENTS = 20;
-
-/**
- * Total attachment payload ceiling for a single request (decoded bytes). Bounds
- * the overall request size even when each individual file is within its limit.
- */
-export const MAX_TOTAL_ATTACHMENT_BYTES = 32 * 1024 * 1024;
-
-/** Whether a media type is an accepted image type. */
-export function isImageMediaType(mediaType: string): boolean {
- return (ACCEPTED_IMAGE_MEDIA_TYPES as readonly string[]).includes(mediaType);
-}
-
-/** Whether a media type is the accepted PDF type. */
-export function isPdfMediaType(mediaType: string): boolean {
- return mediaType === ACCEPTED_PDF_MEDIA_TYPE;
-}
-
-/** Whether a media type is an accepted attachment type at all. */
-export function isAcceptedAttachmentMediaType(mediaType: string): boolean {
- return (ACCEPTED_ATTACHMENT_MEDIA_TYPES as readonly string[]).includes(mediaType);
-}
-
-/**
- * Decoded byte length of a base64 string, computed WITHOUT allocating the
- * decoded buffer. Tolerates an optional `data:<mediaType>;base64,` prefix and
- * any embedded whitespace/newlines. Returns 0 for an empty/whitespace string.
- */
-export function base64ByteLength(b64: string): number {
- // Strip a data-URI prefix if present.
- const comma = b64.indexOf(",");
- const body = b64.startsWith("data:") && comma !== -1 ? b64.slice(comma + 1) : b64;
- let len = 0;
- let pad = 0;
- for (let i = 0; i < body.length; i++) {
- const ch = body.charCodeAt(i);
- // Skip whitespace (space, \t, \n, \r).
- if (ch === 32 || ch === 9 || ch === 10 || ch === 13) continue;
- len++;
- if (body[i] === "=") pad++;
- }
- if (len === 0) return 0;
- // 4 base64 chars → 3 bytes, minus padding.
- return Math.floor((len * 3) / 4) - pad;
-}
-
-export type AttachmentValidationError =
- | { code: "unsupported-type"; mediaType: string }
- | { code: "image-too-large"; mediaType: string; bytes: number; limit: number }
- | { code: "pdf-too-large"; bytes: number; limit: number }
- | { code: "too-many"; count: number; limit: number }
- | { code: "total-too-large"; bytes: number; limit: number }
- | { code: "empty"; mediaType: string };
-
-export interface AttachmentValidationResult {
- ok: boolean;
- errors: AttachmentValidationError[];
-}
-
-/** Extract just the attachment parts from a mixed content list. */
-function attachmentsOf(content: UserContentPart[]): UserAttachmentPart[] {
- return content.filter((p): p is UserAttachmentPart => p.type === "attachment");
-}
-
-/**
- * Validate the attachments in a multimodal user content list against the
- * media-type allowlist and the size/count ceilings. Pure: never throws,
- * collects every violation so the caller can report them all at once.
- *
- * Text parts are ignored (always valid). An empty content list is valid (it's
- * just a text-only message expressed as parts).
- */
-export function validateUserContent(content: UserContentPart[]): AttachmentValidationResult {
- const errors: AttachmentValidationError[] = [];
- const attachments = attachmentsOf(content);
-
- if (attachments.length > MAX_ATTACHMENTS) {
- errors.push({ code: "too-many", count: attachments.length, limit: MAX_ATTACHMENTS });
- }
-
- let total = 0;
- for (const att of attachments) {
- if (!isAcceptedAttachmentMediaType(att.mediaType)) {
- errors.push({ code: "unsupported-type", mediaType: att.mediaType });
- continue;
- }
- const bytes = base64ByteLength(att.data);
- total += bytes;
- if (bytes === 0) {
- errors.push({ code: "empty", mediaType: att.mediaType });
- continue;
- }
- if (isPdfMediaType(att.mediaType)) {
- if (bytes > MAX_PDF_BYTES) {
- errors.push({ code: "pdf-too-large", bytes, limit: MAX_PDF_BYTES });
- }
- } else if (bytes > MAX_IMAGE_BYTES) {
- errors.push({
- code: "image-too-large",
- mediaType: att.mediaType,
- bytes,
- limit: MAX_IMAGE_BYTES,
- });
- }
- }
-
- if (total > MAX_TOTAL_ATTACHMENT_BYTES) {
- errors.push({ code: "total-too-large", bytes: total, limit: MAX_TOTAL_ATTACHMENT_BYTES });
- }
-
- return { ok: errors.length === 0, errors };
-}
-
-/** Convenience: does the content list contain at least one attachment? */
-export function hasAttachments(content: UserContentPart[] | undefined | null): boolean {
- return !!content && content.some((p) => p.type === "attachment");
-}
diff --git a/packages/core/src/models/catalog.ts b/packages/core/src/models/catalog.ts
deleted file mode 100644
index ac310b1..0000000
--- a/packages/core/src/models/catalog.ts
+++ /dev/null
@@ -1,229 +0,0 @@
-import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
-import { dirname } from "node:path";
-
-/**
- * models.dev-backed model catalog. Resolves a model's MAXIMUM context window
- * (`limit.context`) dynamically from the public models.dev API, mirroring how
- * opencode determines per-model context limits — no hardcoded table.
- *
- * The catalog is fetched once, cached on disk with a short TTL, and reused. On
- * fetch failure we fall back to a stale-but-present cache so the lookup keeps
- * working offline. Lookups never throw: an unknown/unreachable model resolves
- * to `null`, which the UI renders as "max unknown".
- */
-
-/** Shape of the slice of models.dev's `/api.json` we consume. */
-interface ModelsDevModel {
- limit?: {
- context?: number;
- output?: number;
- };
- /**
- * Input/output modalities the model accepts. We read `input` to decide
- * whether the model can take image / pdf attachments. Absent on older
- * catalog entries — treated as "unknown" (capability resolves to `null`).
- */
- modalities?: {
- input?: string[];
- output?: string[];
- };
-}
-
-interface ModelsDevProvider {
- id: string;
- models: Record<string, ModelsDevModel | undefined>;
-}
-
-type ModelsDevCatalog = Record<string, ModelsDevProvider | undefined>;
-
-/** Where models.dev's API lives. Overridable for tests / private mirrors. */
-const MODELS_URL = process.env.DISPATCH_MODELS_URL || "https://models.dev";
-
-/** Disk cache path (reuses the repo's `/tmp/dispatch` convention). */
-const CACHE_PATH = "/tmp/dispatch/models-dev.json";
-
-/** How long a cached catalog stays fresh before we re-fetch. */
-const CACHE_TTL_MS = 5 * 60 * 1000;
-
-/** Network timeout for the catalog fetch. */
-const FETCH_TIMEOUT_MS = 10_000;
-
-/**
- * After a failed fetch we memoize the fallback for this long before retrying,
- * so a sustained outage doesn't make every lookup hang on a fresh timeout.
- */
-const FETCH_PENALTY_MS = 60_000;
-
-/**
- * Dispatch provider id → models.dev provider ids to search, in priority order.
- * We only support Claude-backed providers (per product scope). `anthropic` and
- * `opencode-anthropic` are both Claude; we try the first-party `anthropic`
- * catalog first, then the `opencode` gateway catalog as a fallback.
- */
-const PROVIDER_MAP: Record<string, string[]> = {
- anthropic: ["anthropic", "opencode"],
- "opencode-anthropic": ["anthropic", "opencode"],
-};
-
-/** In-process memoized catalog promise (one fetch/parse per TTL window). */
-let cached: { catalog: ModelsDevCatalog; fetchedAt: number } | null = null;
-let inflight: Promise<ModelsDevCatalog> | null = null;
-
-function readDiskCache(): { catalog: ModelsDevCatalog; mtimeMs: number } | null {
- try {
- const stat = statSync(CACHE_PATH);
- const text = readFileSync(CACHE_PATH, "utf-8");
- return { catalog: JSON.parse(text) as ModelsDevCatalog, mtimeMs: stat.mtimeMs };
- } catch {
- return null;
- }
-}
-
-function writeDiskCache(text: string): void {
- try {
- mkdirSync(dirname(CACHE_PATH), { recursive: true });
- // Write-then-rename so a concurrent reader never sees a half-written
- // file (rename is atomic on the same filesystem). The temp name is
- // process-scoped to avoid two writers clobbering each other's temp.
- const tmp = `${CACHE_PATH}.${process.pid}.tmp`;
- writeFileSync(tmp, text, "utf-8");
- renameSync(tmp, CACHE_PATH);
- } catch {
- // Best-effort: a read-only /tmp shouldn't break lookups.
- }
-}
-
-async function fetchCatalog(): Promise<ModelsDevCatalog> {
- const controller = new AbortController();
- const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
- try {
- const res = await fetch(`${MODELS_URL}/api.json`, { signal: controller.signal });
- if (!res.ok) throw new Error(`models.dev returned HTTP ${res.status}`);
- const text = await res.text();
- const catalog = JSON.parse(text) as ModelsDevCatalog;
- writeDiskCache(text);
- return catalog;
- } finally {
- clearTimeout(timer);
- }
-}
-
-/**
- * Load the models.dev catalog, preferring in-process memo, then a fresh disk
- * cache, then a network fetch. On network failure, falls back to any stale
- * disk cache; if nothing is available, returns an empty catalog.
- */
-export async function getModelsCatalog(): Promise<ModelsDevCatalog> {
- if (process.env.DISPATCH_DISABLE_MODELS_FETCH) {
- const disk = readDiskCache();
- return disk?.catalog ?? {};
- }
-
- const now = Date.now();
- if (cached && now - cached.fetchedAt < CACHE_TTL_MS) return cached.catalog;
-
- // Fresh disk cache satisfies the request without a network round-trip.
- const disk = readDiskCache();
- if (disk && now - disk.mtimeMs < CACHE_TTL_MS) {
- // Inherit the file's mtime as `fetchedAt` so loading a disk cache into
- // a fresh process doesn't reset its TTL (which would otherwise double
- // the worst-case staleness across process boundaries).
- cached = { catalog: disk.catalog, fetchedAt: disk.mtimeMs };
- return disk.catalog;
- }
-
- if (!inflight) {
- inflight = fetchCatalog()
- .then((catalog) => {
- cached = { catalog, fetchedAt: Date.now() };
- return catalog;
- })
- .catch((err) => {
- // Network failed — serve a stale cache if we have one.
- console.warn(
- `dispatch: failed to fetch models.dev catalog: ${err instanceof Error ? err.message : String(err)}`,
- );
- const fallback = disk?.catalog ?? ({} as ModelsDevCatalog);
- // Memoize the fallback with a short "penalty" TTL so a sustained
- // outage doesn't make every lookup hang on a fresh 10s timeout.
- // `fetchedAt` is backdated so the memo expires after FETCH_PENALTY_MS.
- cached = {
- catalog: fallback,
- fetchedAt: Date.now() - CACHE_TTL_MS + FETCH_PENALTY_MS,
- };
- return fallback;
- })
- .finally(() => {
- inflight = null;
- });
- }
- return inflight;
-}
-
-/**
- * Resolve a model's maximum context window (in tokens) for the given Dispatch
- * provider + model id. Returns `null` when the provider is unsupported, the
- * model is unknown, or the catalog is unavailable — callers should render that
- * as "max unknown" (no denominator / percentage).
- */
-export async function resolveContextLimit(
- provider: string,
- modelId: string,
-): Promise<number | null> {
- const candidates = PROVIDER_MAP[provider];
- if (!candidates || !modelId) return null;
-
- const catalog = await getModelsCatalog();
- for (const providerId of candidates) {
- const ctx = catalog[providerId]?.models?.[modelId]?.limit?.context;
- if (typeof ctx === "number" && ctx > 0) return ctx;
- }
- return null;
-}
-
-/**
- * Image / PDF input capabilities for a model, resolved from the models.dev
- * catalog's `modalities.input` list.
- */
-export interface ModelInputCapabilities {
- /** Model accepts image input (vision). */
- image: boolean;
- /** Model accepts PDF/document input. */
- pdf: boolean;
-}
-
-/**
- * Resolve whether a model accepts image / pdf input for the given Dispatch
- * provider + model id. Returns `null` when the capability is UNKNOWN — i.e. the
- * provider is unsupported/unmapped, the model is absent from the catalog, the
- * entry predates the `modalities` field, or the catalog is unavailable. Callers
- * should treat `null` as "can't verify" (optimistic allow) rather than a
- * definitive "no", so a temporary catalog outage never disables a known-good
- * vision model.
- *
- * A non-null result means the catalog DID describe the model's input modalities
- * — `{ image, pdf }` then reflects exactly what it advertises (a definitive
- * yes/no for each).
- */
-export async function resolveModelCapabilities(
- provider: string,
- modelId: string,
-): Promise<ModelInputCapabilities | null> {
- const candidates = PROVIDER_MAP[provider];
- if (!candidates || !modelId) return null;
-
- const catalog = await getModelsCatalog();
- for (const providerId of candidates) {
- const input = catalog[providerId]?.models?.[modelId]?.modalities?.input;
- if (Array.isArray(input)) {
- return { image: input.includes("image"), pdf: input.includes("pdf") };
- }
- }
- return null;
-}
-
-/** Test-only: reset the in-process memo so a test can re-exercise loading. */
-export function __resetCatalogCacheForTests(): void {
- cached = null;
- inflight = null;
-}
diff --git a/packages/core/src/models/index.ts b/packages/core/src/models/index.ts
deleted file mode 100644
index 15d1ee2..0000000
--- a/packages/core/src/models/index.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-export {
- ACCEPTED_ATTACHMENT_MEDIA_TYPES,
- ACCEPTED_IMAGE_MEDIA_TYPES,
- ACCEPTED_PDF_MEDIA_TYPE,
- type AttachmentValidationError,
- type AttachmentValidationResult,
- base64ByteLength,
- hasAttachments,
- isAcceptedAttachmentMediaType,
- isImageMediaType,
- isPdfMediaType,
- MAX_ATTACHMENTS,
- MAX_IMAGE_BYTES,
- MAX_PDF_BYTES,
- MAX_TOTAL_ATTACHMENT_BYTES,
- validateUserContent,
-} from "./attachments.js";
-export {
- getModelsCatalog,
- type ModelInputCapabilities,
- resolveContextLimit,
- resolveModelCapabilities,
-} from "./catalog.js";
-export { ModelRegistry } from "./registry.js";
diff --git a/packages/core/src/models/registry.ts b/packages/core/src/models/registry.ts
deleted file mode 100644
index 4a24a51..0000000
--- a/packages/core/src/models/registry.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-import type { KeyDefinition, KeyState } from "../types/index.js";
-
-export class ModelRegistry {
- private keyStates: Map<string, KeyState>;
- private keyOrder: string[];
-
- constructor(keys: KeyDefinition[]) {
- this.keyStates = new Map();
- this.keyOrder = [];
- this._initConfig(keys, new Map());
- }
-
- private _initConfig(keys: KeyDefinition[], existingStates: Map<string, KeyState>): void {
- this.keyOrder = keys.map((k) => k.id);
-
- const newStates = new Map<string, KeyState>();
- for (const key of keys) {
- const existing = existingStates.get(key.id);
- if (existing) {
- // Preserve existing state but update definition
- newStates.set(key.id, { ...existing, definition: key });
- } else {
- newStates.set(key.id, { definition: key, status: "active" });
- }
- }
- this.keyStates = newStates;
- }
-
- getKeys(): KeyState[] {
- return this.keyOrder
- .map((id) => this.keyStates.get(id))
- .filter((state): state is KeyState => state !== undefined);
- }
-
- markKeyExhausted(keyId: string, error?: string): void {
- const state = this.keyStates.get(keyId);
- if (!state) return;
- this.keyStates.set(keyId, {
- ...state,
- status: "exhausted",
- lastError: error,
- exhaustedAt: Date.now(),
- });
- }
-
- markKeyActive(keyId: string): void {
- const state = this.keyStates.get(keyId);
- if (!state) return;
- const updated: KeyState = {
- definition: state.definition,
- status: "active",
- };
- this.keyStates.set(keyId, updated);
- }
-
- hasAvailableKey(provider: string): boolean {
- for (const state of this.keyStates.values()) {
- if (state.definition.provider === provider && state.status === "active") {
- return true;
- }
- }
- return false;
- }
-
- allKeysExhausted(): boolean {
- for (const state of this.keyStates.values()) {
- if (state.status === "active") {
- return false;
- }
- }
- return true;
- }
-
- updateConfig(keys: KeyDefinition[]): void {
- this._initConfig(keys, this.keyStates);
- }
-
- // Internal: get ordered key states for a specific provider
- getOrderedKeysForProvider(provider: string): KeyState[] {
- return this.keyOrder
- .map((id) => this.keyStates.get(id))
- .filter(
- (state): state is KeyState => state !== undefined && state.definition.provider === provider,
- );
- }
-}
diff --git a/packages/core/src/notifications/config.ts b/packages/core/src/notifications/config.ts
deleted file mode 100644
index 49e6ff4..0000000
--- a/packages/core/src/notifications/config.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-// Persisted ntfy config — single global JSON blob under one settings key.
-//
-// One global config (no per-user split): the rest of Dispatch's settings
-// table is global today (cf. `title_model_*`, `perm_*`), so notification
-// config follows the same pattern.
-
-import { deleteSetting, getSetting, setSetting } from "../db/settings.js";
-import type { NotificationEventType, NtfyConfig } from "./types.js";
-import { NTFY_DEFAULT_EVENTS, NTFY_EVENT_TYPES } from "./types.js";
-
-export const NTFY_CONFIG_KEY = "ntfy_config";
-
-/** Defaults returned when nothing is persisted yet. */
-export function defaultNtfyConfig(): NtfyConfig {
- return {
- enabled: false,
- topic: "",
- authToken: "",
- events: { ...NTFY_DEFAULT_EVENTS },
- notifySubagents: false,
- };
-}
-
-/**
- * Normalize an arbitrary parsed JSON value into a complete `NtfyConfig`.
- * Tolerant of missing / unexpected fields so a config from an older build
- * never throws — missing event toggles fall back to defaults.
- */
-export function normalizeNtfyConfig(raw: unknown): NtfyConfig {
- const base = defaultNtfyConfig();
- if (!raw || typeof raw !== "object") return base;
- const obj = raw as Record<string, unknown>;
- const out: NtfyConfig = {
- enabled: typeof obj.enabled === "boolean" ? obj.enabled : base.enabled,
- topic: typeof obj.topic === "string" ? obj.topic : base.topic,
- authToken: typeof obj.authToken === "string" ? obj.authToken : base.authToken,
- events: { ...base.events },
- notifySubagents:
- typeof obj.notifySubagents === "boolean" ? obj.notifySubagents : base.notifySubagents,
- };
- const rawEvents = obj.events;
- if (rawEvents && typeof rawEvents === "object") {
- const evObj = rawEvents as Record<string, unknown>;
- for (const key of NTFY_EVENT_TYPES) {
- const v = evObj[key];
- if (typeof v === "boolean") out.events[key as NotificationEventType] = v;
- }
- }
- return out;
-}
-
-/** Load the persisted config (or defaults if none/corrupt). */
-export function loadNtfyConfig(): NtfyConfig {
- const raw = getSetting(NTFY_CONFIG_KEY);
- if (!raw) return defaultNtfyConfig();
- try {
- return normalizeNtfyConfig(JSON.parse(raw));
- } catch {
- return defaultNtfyConfig();
- }
-}
-
-/** Persist a complete config (after server-side normalization). */
-export function saveNtfyConfig(config: NtfyConfig): void {
- const normalized = normalizeNtfyConfig(config);
- setSetting(NTFY_CONFIG_KEY, JSON.stringify(normalized));
-}
-
-/** Wipe the persisted config (revert to defaults on next load). */
-export function clearNtfyConfig(): void {
- deleteSetting(NTFY_CONFIG_KEY);
-}
-
-/** Strip the auth token from a config before returning it over the API. */
-export function redactNtfyConfig(config: NtfyConfig): NtfyConfig & { hasAuthToken: boolean } {
- return { ...config, authToken: "", hasAuthToken: config.authToken.trim().length > 0 };
-}
diff --git a/packages/core/src/notifications/dispatcher.ts b/packages/core/src/notifications/dispatcher.ts
deleted file mode 100644
index 01ce00c..0000000
--- a/packages/core/src/notifications/dispatcher.ts
+++ /dev/null
@@ -1,287 +0,0 @@
-// NotificationDispatcher — turns high-level Dispatch events into
-// `sendNtfy(...)` calls, gated by the persisted user config.
-//
-// The dispatcher is transport-agnostic at the `notify(event)` interface
-// boundary: only `sendNtfy` is wired today, but adding another transport
-// (email, webhook, etc.) means changing this one file, not the call sites.
-//
-// All sends are non-blocking (fire-and-forget). A 10-second timeout in
-// `sendNtfy` bounds the worst case; the dispatcher additionally guards
-// every send in a try/catch so a transport bug can never propagate into
-// the agent loop.
-
-import { loadNtfyConfig } from "./config.js";
-import { type FetchLike, sendNtfy } from "./ntfy.js";
-import type { NotificationEvent, NtfyConfig } from "./types.js";
-
-/** Minimal shape of an `AgentManager`-style event stream we hook into. */
-export interface AgentEventSource {
- onEvent(
- listener: (event: { type: string; tabId: string; [key: string]: unknown }) => void,
- ): () => void;
-}
-
-/** Minimal shape of a `PermissionManager`-style prompt source. */
-export interface PermissionPromptSource {
- onPromptAdded(
- listener: (prompt: { id: string; permission: string; description: string }) => void,
- ): () => void;
-}
-
-/** Look up a human-readable tab title for nicer notification text. */
-export type TabTitleLookup = (tabId: string) => string | null;
-
-/**
- * Look up a tab's `parentTabId`. Returns `null` for top-level tabs (no
- * parent) and `undefined` when the lookup can't be performed (no DB, tab
- * not found). Both non-strings cause the dispatcher to fall back to
- * "treat as top-level" to avoid silently dropping notifications when the
- * lookup is broken.
- */
-export type TabParentLookup = (tabId: string) => string | null | undefined;
-
-export interface DispatcherOptions {
- /** Override the config loader (tests). Defaults to `loadNtfyConfig`. */
- loadConfig?: () => NtfyConfig;
- /** Override the transport (tests). Defaults to the real `sendNtfy`. */
- send?: (config: NtfyConfig, event: NotificationEvent) => Promise<unknown>;
- /** Optional fetch override (forwarded to `sendNtfy` when `send` not set). */
- fetchImpl?: FetchLike;
- /** Look up a tab title for richer titles. */
- getTabTitle?: TabTitleLookup;
- /**
- * Look up a tab's `parentTabId`. Used to honour the
- * `notifySubagents` config flag — when false, `turn-completed` /
- * `turn-error` from subagent tabs (those with a parent) are
- * suppressed.
- */
- getTabParentId?: TabParentLookup;
- /**
- * How long (ms) a dedupeKey is suppressed for. Permission prompts re-emit
- * the whole pending list on every change, so dedupe is essential.
- */
- dedupeWindowMs?: number;
-}
-
-export class NotificationDispatcher {
- private loadConfig: () => NtfyConfig;
- private send: (config: NtfyConfig, event: NotificationEvent) => Promise<unknown>;
- private getTabTitle: TabTitleLookup | undefined;
- private getTabParentId: TabParentLookup | undefined;
- private dedupeWindowMs: number;
- /** Recently-sent dedupeKey → expiresAt epoch ms. */
- private recentlySent = new Map<string, number>();
- private unsubs: Array<() => void> = [];
-
- constructor(opts: DispatcherOptions = {}) {
- this.loadConfig = opts.loadConfig ?? loadNtfyConfig;
- this.send =
- opts.send ?? ((config, event) => sendNtfy(config, event, opts.fetchImpl ?? undefined));
- this.getTabTitle = opts.getTabTitle;
- this.getTabParentId = opts.getTabParentId;
- this.dedupeWindowMs = opts.dedupeWindowMs ?? 5_000;
- }
-
- /**
- * Single internal entry point — every public hook funnels through here.
- * Public so a future caller can synthesize an arbitrary notification
- * (e.g. a CLI `dispatch notify` command); kept narrow.
- */
- notify(event: NotificationEvent): void {
- const config = this.loadConfig();
- if (!config.enabled) return;
- if (!config.events[event.type]) return;
- if (event.dedupeKey && this.isDuplicate(event.dedupeKey)) return;
- if (event.dedupeKey) this.markSent(event.dedupeKey);
-
- // Fire-and-forget: never await, never throw.
- try {
- void Promise.resolve(this.send(config, event)).catch((err) => {
- console.warn(
- `[ntfy] send failed for ${event.type}: ${err instanceof Error ? err.message : String(err)}`,
- );
- });
- } catch (err) {
- // Guard the synchronous portion of `send` too.
- console.warn(
- `[ntfy] dispatch threw for ${event.type}: ${err instanceof Error ? err.message : String(err)}`,
- );
- }
- }
-
- /**
- * Hook into an `AgentManager`-style event stream.
- *
- * Maps:
- * - `done` → `turn-completed`
- * - `error` → `turn-error`
- * - `tab-created` → `agent-spawned` (only top-level user-agent tabs)
- *
- * `status` events are ignored — they fire on every transition and we'd
- * either spam or duplicate the `done`/`error` notifications.
- *
- * Turn events from subagent tabs are suppressed when
- * `config.notifySubagents === false` (the default). A parent agent
- * spawning 8 subagents would otherwise produce 9 "Turn complete"
- * pushes per round; almost always noise. Permission prompts are NOT
- * gated this way — a subagent's permission request still needs human
- * input to proceed, so suppressing those would silently hang the
- * subagent.
- */
- attachToAgentManager(source: AgentEventSource): () => void {
- const unsub = source.onEvent((event) => {
- if (event.type === "done") {
- if (this.isSubagentSuppressed(event.tabId)) return;
- this.notify(this.buildTurnCompleted(event));
- } else if (event.type === "error") {
- if (this.isSubagentSuppressed(event.tabId)) return;
- this.notify(this.buildTurnError(event));
- } else if (event.type === "tab-created") {
- const ev = event as unknown as {
- tabId: string;
- id: string;
- title: string;
- parentTabId: string | null;
- agentSlug?: string | null;
- };
- // Only notify for top-level user-agent tabs spawned via `summon`.
- // Filtering on `agentSlug` skips "blank" new tabs the user opened
- // manually, which would be noisy.
- if (ev.parentTabId === null && ev.agentSlug) {
- this.notify(this.buildAgentSpawned(ev));
- }
- }
- });
- this.unsubs.push(unsub);
- return unsub;
- }
-
- /** Hook into a `PermissionManager`-style prompt source. */
- attachToPermissionManager(source: PermissionPromptSource): () => void {
- const unsub = source.onPromptAdded((prompt) => {
- this.notify(this.buildPermissionRequired(prompt));
- });
- this.unsubs.push(unsub);
- return unsub;
- }
-
- /** Release all hooks acquired via `attachTo*`. */
- dispose(): void {
- for (const u of this.unsubs) {
- try {
- u();
- } catch {
- // best-effort
- }
- }
- this.unsubs = [];
- this.recentlySent.clear();
- }
-
- // ─── Event builders (internal) ────────────────────────────────
-
- private buildTurnCompleted(event: { tabId: string }): NotificationEvent {
- const tabLabel = this.tabLabel(event.tabId);
- return {
- type: "turn-completed",
- title: `Turn complete — ${tabLabel}`,
- message: `Assistant finished a turn in ${tabLabel}.`,
- tabId: event.tabId,
- };
- }
-
- private buildTurnError(event: {
- tabId: string;
- error?: unknown;
- statusCode?: unknown;
- }): NotificationEvent {
- const tabLabel = this.tabLabel(event.tabId);
- const errText = typeof event.error === "string" ? event.error : "Unknown error";
- const statusText = typeof event.statusCode === "number" ? ` (status ${event.statusCode})` : "";
- return {
- type: "turn-error",
- title: `Turn failed — ${tabLabel}`,
- message: `${errText}${statusText}`,
- tabId: event.tabId,
- };
- }
-
- private buildPermissionRequired(prompt: {
- id: string;
- permission: string;
- description: string;
- }): NotificationEvent {
- return {
- type: "permission-required",
- title: `Permission required: ${prompt.permission}`,
- message: prompt.description || `Agent is requesting ${prompt.permission} permission.`,
- // Permission prompts can re-emit (e.g. another prompt arrives while
- // this one is still pending) — dedupe on the prompt id.
- dedupeKey: `permission:${prompt.id}`,
- };
- }
-
- private buildAgentSpawned(ev: {
- tabId: string;
- id: string;
- title: string;
- agentSlug?: string | null;
- }): NotificationEvent {
- return {
- type: "agent-spawned",
- title: `User agent spawned — ${ev.agentSlug ?? "agent"}`,
- message: ev.title,
- tabId: ev.tabId ?? ev.id,
- };
- }
-
- private tabLabel(tabId: string): string {
- const title = this.getTabTitle?.(tabId);
- if (title?.trim()) return title.trim();
- return `tab ${tabId.slice(0, 8)}`;
- }
-
- /**
- * Returns true when this `tabId` belongs to a subagent AND the user has
- * opted out of subagent turn notifications. On lookup failure
- * (`getTabParentId` returns `undefined` or throws) we err on the side
- * of "not a subagent" — better to over-notify than to silently drop
- * legitimate top-level events when the DB is briefly unreadable.
- */
- private isSubagentSuppressed(tabId: string): boolean {
- const config = this.loadConfig();
- if (config.notifySubagents) return false;
- if (!this.getTabParentId) return false;
- let parent: string | null | undefined;
- try {
- parent = this.getTabParentId(tabId);
- } catch {
- return false;
- }
- // Only a non-empty string parent id means "this tab is a subagent".
- return typeof parent === "string" && parent.length > 0;
- }
-
- // ─── Dedupe helpers ───────────────────────────────────────────
-
- private isDuplicate(key: string): boolean {
- const expires = this.recentlySent.get(key);
- if (expires === undefined) return false;
- if (expires <= Date.now()) {
- this.recentlySent.delete(key);
- return false;
- }
- return true;
- }
-
- private markSent(key: string): void {
- // Lazy-evict expired entries when the map gets large.
- if (this.recentlySent.size > 256) {
- const now = Date.now();
- for (const [k, exp] of this.recentlySent) {
- if (exp <= now) this.recentlySent.delete(k);
- }
- }
- this.recentlySent.set(key, Date.now() + this.dedupeWindowMs);
- }
-}
diff --git a/packages/core/src/notifications/index.ts b/packages/core/src/notifications/index.ts
deleted file mode 100644
index d1e7891..0000000
--- a/packages/core/src/notifications/index.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-// @dispatch/core — ntfy.sh push notifications
-
-export {
- clearNtfyConfig,
- defaultNtfyConfig,
- loadNtfyConfig,
- NTFY_CONFIG_KEY,
- normalizeNtfyConfig,
- redactNtfyConfig,
- saveNtfyConfig,
-} from "./config.js";
-export {
- type AgentEventSource,
- type DispatcherOptions,
- NotificationDispatcher,
- type PermissionPromptSource,
- type TabParentLookup,
- type TabTitleLookup,
-} from "./dispatcher.js";
-export {
- buildNtfyUrl,
- type FetchLike,
- NTFY_BASE_URL,
- type NtfySendResult,
- sendNtfy,
-} from "./ntfy.js";
-export {
- type NotificationEvent,
- type NotificationEventType,
- NTFY_DEFAULT_EVENTS,
- NTFY_DEFAULT_PRIORITIES,
- NTFY_DEFAULT_TAGS,
- NTFY_EVENT_TYPES,
- type NtfyConfig,
- type NtfyPriority,
-} from "./types.js";
diff --git a/packages/core/src/notifications/ntfy.ts b/packages/core/src/notifications/ntfy.ts
deleted file mode 100644
index eb5de9e..0000000
--- a/packages/core/src/notifications/ntfy.ts
+++ /dev/null
@@ -1,149 +0,0 @@
-// ntfy.sh HTTP transport.
-//
-// ntfy's API is a simple POST to `https://ntfy.sh/<topic>` with the body
-// as the message and metadata passed via HTTP headers:
-// Title: notification title
-// Priority: 1..5 (3 = default)
-// Tags: comma-separated emoji shortcodes
-// Click: URL opened when the notification is tapped
-//
-// The server is hardcoded to the public ntfy.sh instance; the user only
-// configures a topic name. We intentionally use `fetch` directly — no
-// SDK, no extra deps.
-
-import type { NotificationEvent, NtfyConfig } from "./types.js";
-import { NTFY_DEFAULT_PRIORITIES, NTFY_DEFAULT_TAGS } from "./types.js";
-
-export interface NtfySendResult {
- ok: boolean;
- status?: number;
- error?: string;
-}
-
-/**
- * Lightweight fetch shape so callers (and tests) can inject a mock without
- * pulling in the DOM `fetch` type from a `Headers` instance.
- */
-export type FetchLike = (
- input: string,
- init: { method: string; headers: Record<string, string>; body: string; signal?: AbortSignal },
-) => Promise<{ ok: boolean; status: number; statusText?: string; text(): Promise<string> }>;
-
-/** Base URL of the public ntfy.sh server. */
-export const NTFY_BASE_URL = "https://ntfy.sh";
-
-/**
- * Build the publish URL for a topic name.
- *
- * No client-side validation of the topic content: ntfy.sh's accepted
- * character set has changed over time and a regex here only locks users
- * out of legitimate topics. The topic is URL-encoded so the resulting
- * URL is always syntactically valid; if ntfy rejects the name the HTTP
- * error surfaces on the first send / `Send test`.
- */
-export function buildNtfyUrl(topic: string): string {
- return `${NTFY_BASE_URL}/${encodeURIComponent(topic.trim())}`;
-}
-
-/**
- * Send a single notification to the configured ntfy topic.
- *
- * Fire-and-forget at call sites: the dispatcher uses
- * `void sendNtfy(...).catch(...)` so a slow/broken ntfy server never blocks
- * a turn. We still return a structured result so the explicit
- * `POST /notifications/test` route can surface failures back to the UI.
- *
- * Pure with respect to `config` / `event` — no DB, no module state.
- */
-export async function sendNtfy(
- config: NtfyConfig,
- event: NotificationEvent,
- fetchImpl: FetchLike = globalThis.fetch as unknown as FetchLike,
- timeoutMs = 10_000,
-): Promise<NtfySendResult> {
- if (!config.enabled) return { ok: false, error: "Notifications are disabled" };
- if (!config.topic.trim()) return { ok: false, error: "Topic is required" };
- const targetUrl = buildNtfyUrl(config.topic);
-
- const priority = event.priority ?? NTFY_DEFAULT_PRIORITIES[event.type] ?? 3;
- const baseTags = event.tags ?? NTFY_DEFAULT_TAGS[event.type] ?? [];
- const tags = [...baseTags];
- if (event.tabId) {
- // Short, ASCII-only tag so ntfy's comma-separated header parser is happy.
- tags.push(`tab-${event.tabId.slice(0, 8)}`);
- }
-
- const headers: Record<string, string> = {
- // ntfy is tolerant of non-ASCII in the Title header but many proxies
- // aren't — sanitizeHeader strips CR/LF/control chars (injection guard)
- // and leaves UTF-8 in place. Body is sent verbatim as UTF-8.
- Title: sanitizeHeader(event.title),
- Priority: String(priority),
- "Content-Type": "text/plain; charset=utf-8",
- };
- if (tags.length > 0) headers.Tags = tags.map(sanitizeHeader).join(",");
- if (event.clickUrl) headers.Click = sanitizeHeader(event.clickUrl);
- const authValue = buildAuthHeaderValue(config.authToken);
- if (authValue) headers.Authorization = authValue;
-
- // Per-request abort so a hung server doesn't pin a Bun worker forever.
- const controller = new AbortController();
- const timer = setTimeout(() => controller.abort(), timeoutMs);
-
- try {
- const res = await fetchImpl(targetUrl, {
- method: "POST",
- headers,
- body: event.message,
- signal: controller.signal,
- });
- if (!res.ok) {
- const text = await safeReadText(res);
- return {
- ok: false,
- status: res.status,
- error: `ntfy responded ${res.status} ${res.statusText ?? ""}: ${text}`.trim(),
- };
- }
- return { ok: true, status: res.status };
- } catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
- return { ok: false, error: msg };
- } finally {
- clearTimeout(timer);
- }
-}
-
-/**
- * Build the `Authorization` header value from the user-configured token.
- *
- * - Empty/blank ⇒ no header.
- * - Already starts with `Bearer ` / `Basic ` / etc. (RFC 7235 scheme + space)
- * ⇒ used verbatim. Lets users paste a complete header for Basic auth or
- * any other scheme their private ntfy server supports.
- * - Otherwise ⇒ prefixed with `Bearer ` (the common case).
- */
-function buildAuthHeaderValue(rawToken: string): string | null {
- const trimmed = (rawToken ?? "").trim();
- if (!trimmed) return null;
- if (/^[A-Za-z][A-Za-z0-9._~+/-]*\s+\S/.test(trimmed)) {
- // Already includes a scheme token (e.g. "Bearer xyz", "Basic dXNlcjpw").
- return sanitizeHeader(trimmed);
- }
- return `Bearer ${sanitizeHeader(trimmed)}`;
-}
-
-function sanitizeHeader(value: string): string {
- // Strip CR/LF (header injection guard) and other control chars, then trim.
- // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional
- return value.replace(/[\r\n\u0000-\u001f]+/g, " ").trim();
-}
-
-async function safeReadText(res: { text(): Promise<string> }): Promise<string> {
- try {
- const t = await res.text();
- return t.length > 200 ? `${t.slice(0, 200)}…` : t;
- } catch {
- return "";
- }
-}
diff --git a/packages/core/src/notifications/types.ts b/packages/core/src/notifications/types.ts
deleted file mode 100644
index ef6c059..0000000
--- a/packages/core/src/notifications/types.ts
+++ /dev/null
@@ -1,108 +0,0 @@
-// ntfy.sh push notifications — types
-
-/**
- * Catalog of notification-worthy events.
- *
- * Kept intentionally small and stable: each entry is something a human
- * actually wants pushed to their phone. New event types should be added
- * with a sensible default (`NTFY_DEFAULT_EVENTS`) and a mapping in the
- * dispatcher.
- */
-export type NotificationEventType =
- | "turn-completed"
- | "turn-error"
- | "permission-required"
- | "agent-spawned";
-
-/** ntfy priority levels (1=min … 5=max). */
-export type NtfyPriority = 1 | 2 | 3 | 4 | 5;
-
-/**
- * A single notification request. Synthesised by the dispatcher from a
- * higher-level event source (AgentManager / PermissionManager); fed to
- * the ntfy transport.
- *
- * `dedupeKey` lets the dispatcher suppress duplicate sends (e.g. the
- * permission system re-emits the pending list on every change).
- */
-export interface NotificationEvent {
- type: NotificationEventType;
- /** Notification title (short). */
- title: string;
- /** Notification body. */
- message: string;
- /** Optional ntfy tags (emoji shortcodes — e.g. `["white_check_mark"]`). */
- tags?: string[];
- /** Optional priority override. Defaults are per-event-type. */
- priority?: NtfyPriority;
- /** Optional URL the notification deep-links to when tapped. */
- clickUrl?: string;
- /** Origin tab id (informational; included in tags as `tab:<short>`). */
- tabId?: string;
- /**
- * Stable key for suppressing duplicates. Same key + same type within a
- * short window ⇒ dropped silently.
- */
- dedupeKey?: string;
-}
-
-/**
- * Persisted ntfy configuration. Lives in the `settings` table under a
- * single key (`ntfy_config`) — one global config, matching the codebase's
- * existing single-user assumption (cf. `title_model_*`, `perm_*`).
- *
- * - `enabled` — master switch. Off ⇒ dispatcher never sends.
- * - `topic` — bare ntfy.sh topic name, e.g. `my-secret-topic`. The
- * server is hardcoded to https://ntfy.sh; the user only picks a topic.
- * Missing ⇒ dispatcher never sends.
- * - `authToken` — optional bearer token (rarely needed against ntfy.sh
- * directly; preserved for users behind an auth-protected proxy).
- * - `events` — per-event-type enable map. Missing entries default to OFF
- * so a newly-added event type doesn't silently start firing.
- * - `notifySubagents` — when false (default), `turn-completed` and
- * `turn-error` notifications from subagent tabs (tabs with a
- * `parentTabId`) are suppressed. A parent agent that spawns 8
- * subagents would otherwise push 9 "Turn complete" notifications per
- * round — usually noise. `permission-required` is NOT gated: even a
- * subagent's permission prompt needs a human tap to proceed.
- * `agent-spawned` is already top-level-only by construction.
- */
-export interface NtfyConfig {
- enabled: boolean;
- topic: string;
- authToken: string;
- events: Record<NotificationEventType, boolean>;
- notifySubagents: boolean;
-}
-
-/** All event types this build knows about (the source of truth for UI). */
-export const NTFY_EVENT_TYPES: NotificationEventType[] = [
- "turn-completed",
- "turn-error",
- "permission-required",
- "agent-spawned",
-];
-
-/** Default per-event-type toggles. */
-export const NTFY_DEFAULT_EVENTS: Record<NotificationEventType, boolean> = {
- "turn-completed": true,
- "turn-error": true,
- "permission-required": true,
- "agent-spawned": false,
-};
-
-/** Default priority per event type (when the event itself doesn't override). */
-export const NTFY_DEFAULT_PRIORITIES: Record<NotificationEventType, NtfyPriority> = {
- "turn-completed": 3,
- "turn-error": 4,
- "permission-required": 4,
- "agent-spawned": 2,
-};
-
-/** Default tag (emoji) per event type. */
-export const NTFY_DEFAULT_TAGS: Record<NotificationEventType, string[]> = {
- "turn-completed": ["white_check_mark"],
- "turn-error": ["rotating_light"],
- "permission-required": ["lock"],
- "agent-spawned": ["sparkles"],
-};
diff --git a/packages/core/src/permission/evaluate.ts b/packages/core/src/permission/evaluate.ts
deleted file mode 100644
index 7a0d235..0000000
--- a/packages/core/src/permission/evaluate.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import type { PermissionRule, Ruleset } from "./index.js";
-import { Wildcard } from "./wildcard.js";
-
-export function evaluate(
- permission: string,
- pattern: string,
- ...rulesets: Ruleset[]
-): PermissionRule {
- const flat = ([] as PermissionRule[]).concat(...rulesets);
- const match = flat.findLast(
- (rule) => Wildcard.match(rule.permission, permission) && Wildcard.match(rule.pattern, pattern),
- );
- if (match) return match;
- return { action: "ask", permission, pattern };
-}
diff --git a/packages/core/src/permission/index.ts b/packages/core/src/permission/index.ts
deleted file mode 100644
index bf6efaf..0000000
--- a/packages/core/src/permission/index.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-export interface PermissionRule {
- permission: string;
- pattern: string;
- action: "allow" | "deny" | "ask";
-}
-
-export type Ruleset = PermissionRule[];
-
-export type PermissionReply = "once" | "always" | "reject";
-
-export interface PermissionRequest {
- permission: string;
- patterns: string[];
- always: string[];
- description: string;
- metadata: Record<string, unknown>;
-}
-
-export interface PermissionChecker {
- ask(request: PermissionRequest, rulesets: Ruleset[]): Promise<PermissionReply>;
- getPending(): Array<{ id: string; request: PermissionRequest }>;
-}
-
-export { evaluate } from "./evaluate.js";
-export { PermissionService } from "./service.js";
-export { Wildcard } from "./wildcard.js";
diff --git a/packages/core/src/permission/service.ts b/packages/core/src/permission/service.ts
deleted file mode 100644
index 1c8c6f0..0000000
--- a/packages/core/src/permission/service.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-import { evaluate } from "./evaluate.js";
-import type { PermissionReply, PermissionRequest, PermissionRule, Ruleset } from "./index.js";
-
-export class PermissionService {
- private pending: Map<
- string,
- {
- request: PermissionRequest;
- resolve: (reply: PermissionReply) => void;
- reject: (err: Error) => void;
- }
- > = new Map();
- private approved: Ruleset = [];
- private idCounter = 0;
-
- approve(rules: PermissionRule[]): void {
- this.approved.push(...rules);
- }
-
- async ask(request: PermissionRequest, rulesets: Ruleset[]): Promise<PermissionReply> {
- // Evaluate against all rulesets + approved (approved overrides) for ALL patterns
- const patterns = request.always.length > 0 ? request.always : ["*"];
- const results = patterns.map((pattern) =>
- evaluate(request.permission, pattern, ...rulesets, this.approved),
- );
-
- // Any deny → deny
- const denied = results.find((r) => r.action === "deny");
- if (denied) {
- throw new Error(`Permission denied: ${request.permission} ${denied.pattern}`);
- }
-
- // All allow → allow
- if (results.every((r) => r.action === "allow")) {
- return "once";
- }
-
- // action === "ask" — create a pending request
- const id = String(++this.idCounter);
- return new Promise<PermissionReply>((resolve, reject) => {
- this.pending.set(id, { request, resolve, reject });
- });
- }
-
- reply(id: string, reply: PermissionReply): void {
- if (reply === "reject") {
- // Reject cascade — reject all pending
- for (const [pendingId, entry] of this.pending) {
- entry.reject(new Error(`Permission rejected: ${entry.request.permission}`));
- this.pending.delete(pendingId);
- }
- return;
- }
-
- this.resolvePending(id, reply);
- }
-
- getPending(): Array<{ id: string; request: PermissionRequest }> {
- return Array.from(this.pending.entries()).map(([id, { request }]) => ({ id, request }));
- }
-
- private resolvePending(id: string, reply: PermissionReply): void {
- const entry = this.pending.get(id);
- if (!entry) return;
-
- if (reply === "always") {
- // Add rules for each pattern in request.patterns
- for (const pattern of entry.request.patterns) {
- this.approved.push({ permission: entry.request.permission, pattern, action: "allow" });
- }
- }
-
- entry.resolve(reply);
- this.pending.delete(id);
- }
-}
diff --git a/packages/core/src/permission/wildcard.ts b/packages/core/src/permission/wildcard.ts
deleted file mode 100644
index 7874680..0000000
--- a/packages/core/src/permission/wildcard.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-export const Wildcard = {
- match(pattern: string, value: string): boolean {
- // Escape regex special chars except * and ?
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
- // Convert wildcards
- const regexStr = escaped.replace(/\*/g, ".*").replace(/\?/g, ".");
- const regex = new RegExp(`^${regexStr}$`, "s");
- return regex.test(value);
- },
-};
diff --git a/packages/core/src/skills/index.ts b/packages/core/src/skills/index.ts
deleted file mode 100644
index a3fac70..0000000
--- a/packages/core/src/skills/index.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-export {
- createSkillsWatcher,
- getSkillByName,
- loadSkills,
- resolveSkillsForAgent,
-} from "./loader.js";
-export { parseSkillFile } from "./parser.js";
diff --git a/packages/core/src/skills/loader.ts b/packages/core/src/skills/loader.ts
deleted file mode 100644
index 5d043dd..0000000
--- a/packages/core/src/skills/loader.ts
+++ /dev/null
@@ -1,248 +0,0 @@
-import * as fs from "node:fs";
-import * as os from "node:os";
-import * as path from "node:path";
-import chokidar from "chokidar";
-import type { AgentSkillMapping, SkillDefinition, SkillScope } from "../types/index.js";
-import { parseSkillFile } from "./parser.js";
-
-// ─── Internal Helpers ────────────────────────────────────────────
-
-/**
- * Recursively scan a directory for .md skill files.
- * The `directory` field on each skill is the relative path from `baseDir` to the file's parent.
- * Skips the `agents/` subdirectory (handled separately).
- */
-function scanSkillsRecursive(baseDir: string, scope: SkillScope): SkillDefinition[] {
- if (!fs.existsSync(baseDir)) return [];
-
- const results: SkillDefinition[] = [];
-
- function walk(dir: string) {
- let entries: fs.Dirent[];
- try {
- entries = fs.readdirSync(dir, { withFileTypes: true });
- } catch {
- return;
- }
-
- for (const entry of entries) {
- const fullPath = path.join(dir, entry.name);
-
- if (entry.isDirectory()) {
- // Skip agents/ at the top level (handled by loadAgentMappings)
- const relFromBase = path.relative(baseDir, fullPath);
- if (relFromBase === "agents") continue;
- walk(fullPath);
- } else if (entry.isFile() && entry.name.endsWith(".md")) {
- const relDir = path.relative(baseDir, dir);
- // relDir is "" for root, "general" for general/, "general/webapps" for nested
- const directory = relDir === "." ? "" : relDir;
- try {
- const content = fs.readFileSync(fullPath, "utf-8");
- const skill = parseSkillFile(fullPath, content, scope, directory);
- results.push(skill);
- } catch {
- // Skip unreadable files
- }
- }
- }
- }
-
- walk(baseDir);
- return results;
-}
-
-function loadAgentMappings(agentsDir: string, scope: SkillScope): AgentSkillMapping[] {
- if (!fs.existsSync(agentsDir)) {
- return [];
- }
-
- const results: AgentSkillMapping[] = [];
- let entries: fs.Dirent[];
- try {
- entries = fs.readdirSync(agentsDir, { withFileTypes: true });
- } catch {
- return [];
- }
-
- for (const entry of entries) {
- if (!entry.isFile() || !entry.name.endsWith(".txt")) {
- continue;
- }
-
- const fileName = entry.name;
- let isOrchestrator = false;
- let agentType: string;
-
- if (fileName.endsWith(".o.txt")) {
- isOrchestrator = true;
- agentType = fileName.slice(0, -6); // remove ".o.txt"
- } else {
- agentType = fileName.slice(0, -4); // remove ".txt"
- }
-
- const filePath = path.join(agentsDir, fileName);
- try {
- const content = fs.readFileSync(filePath, "utf-8");
- const skills = content
- .split("\n")
- .map((line) => line.trim())
- .filter((line) => line.length > 0 && !line.startsWith("#"));
-
- results.push({ agentType, isOrchestrator, skills, scope });
- } catch {
- // Skip unreadable mapping files
- }
- }
-
- return results;
-}
-
-// ─── Public API ──────────────────────────────────────────────────
-
-export function loadSkills(projectDir: string): {
- skills: SkillDefinition[];
- mappings: AgentSkillMapping[];
-} {
- const globalBase = path.join(os.homedir(), ".skills");
- const projectBase = path.join(projectDir, ".skills");
-
- const skills: SkillDefinition[] = [];
- const mappings: AgentSkillMapping[] = [];
-
- // 1. Scan all global skills recursively (skipping agents/)
- skills.push(...scanSkillsRecursive(globalBase, "global"));
-
- // 2. Scan all project skills recursively (skipping agents/)
- skills.push(...scanSkillsRecursive(projectBase, "project"));
-
- // 3. Agent mappings — global then project
- mappings.push(...loadAgentMappings(path.join(globalBase, "agents"), "global"));
- mappings.push(...loadAgentMappings(path.join(projectBase, "agents"), "project"));
-
- return { skills, mappings };
-}
-
-export function resolveSkillsForAgent(
- agentType: string,
- isOrchestrator: boolean,
- skills: SkillDefinition[],
- mappings: AgentSkillMapping[],
-): SkillDefinition[] {
- // Helper: project overrides global for same-named skills
- const dedupeByName = (list: SkillDefinition[]): SkillDefinition[] => {
- const seen = new Map<string, SkillDefinition>();
- for (const skill of list) {
- const existing = seen.get(skill.name);
- if (!existing || skill.scope === "project") {
- seen.set(skill.name, skill);
- }
- }
- return Array.from(seen.values());
- };
-
- // All default-directory skills (global first, then project — dedupe preserves project)
- const defaultSkills = skills.filter((s) => s.directory === "default");
-
- // Skills mapped to this agent type
- const relevantMappings = mappings.filter(
- (m) => m.agentType === agentType && m.isOrchestrator === isOrchestrator,
- );
-
- // Gather agent-specific skills in order: global mappings first, then project
- const globalMappings = relevantMappings.filter((m) => m.scope === "global");
- const projectMappings = relevantMappings.filter((m) => m.scope === "project");
-
- const agentSkillNames: string[] = [];
- for (const mapping of [...globalMappings, ...projectMappings]) {
- for (const skillFile of mapping.skills) {
- const skillName = path.basename(skillFile, path.extname(skillFile));
- agentSkillNames.push(skillName);
- }
- }
-
- const agentSpecificSkills = agentSkillNames
- .map((name) => getSkillByName(name, skills, "project"))
- .filter((s): s is SkillDefinition => s !== undefined);
-
- const combined = [...defaultSkills, ...agentSpecificSkills];
- return dedupeByName(combined);
-}
-
-export function getSkillByName(
- name: string,
- skills: SkillDefinition[],
- preferScope?: SkillScope,
-): SkillDefinition | undefined {
- const matches = skills.filter((s) => s.name === name);
- if (matches.length === 0) {
- return undefined;
- }
-
- if (preferScope) {
- const preferred = matches.find((s) => s.scope === preferScope);
- if (preferred) {
- return preferred;
- }
- }
-
- // Default: project takes precedence
- const projectMatch = matches.find((s) => s.scope === "project");
- return projectMatch ?? matches[0];
-}
-
-export function createSkillsWatcher(
- projectDir: string,
- onChange: (result: { skills: SkillDefinition[]; mappings: AgentSkillMapping[] }) => void,
-): { close(): void } {
- const globalBase = path.join(os.homedir(), ".skills");
- const projectBase = path.join(projectDir, ".skills");
-
- let debounceTimer: ReturnType<typeof setTimeout> | null = null;
-
- const reload = () => {
- if (debounceTimer !== null) {
- clearTimeout(debounceTimer);
- }
- debounceTimer = setTimeout(() => {
- debounceTimer = null;
- const result = loadSkills(projectDir);
- onChange(result);
- }, 300);
- };
-
- const watchPaths = [globalBase, projectBase];
-
- const watcher = chokidar.watch(watchPaths, {
- ignoreInitial: true,
- persistent: true,
- });
-
- watcher.on("add", (filePath: string) => {
- if (filePath.endsWith(".md") || filePath.endsWith(".txt")) {
- reload();
- }
- });
-
- watcher.on("change", (filePath: string) => {
- if (filePath.endsWith(".md") || filePath.endsWith(".txt")) {
- reload();
- }
- });
-
- watcher.on("unlink", (filePath: string) => {
- if (filePath.endsWith(".md") || filePath.endsWith(".txt")) {
- reload();
- }
- });
-
- return {
- close() {
- if (debounceTimer !== null) {
- clearTimeout(debounceTimer);
- debounceTimer = null;
- }
- watcher.close();
- },
- };
-}
diff --git a/packages/core/src/skills/parser.ts b/packages/core/src/skills/parser.ts
deleted file mode 100644
index bd1205e..0000000
--- a/packages/core/src/skills/parser.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import * as path from "node:path";
-import { parse } from "smol-toml";
-import type { SkillDefinition, SkillDirectory, SkillScope } from "../types/index.js";
-
-const FRONTMATTER_DELIMITER = "+++";
-
-export function parseSkillFile(
- filePath: string,
- content: string,
- scope: SkillScope,
- directory: SkillDirectory,
-): SkillDefinition {
- const defaultName = path.basename(filePath, path.extname(filePath));
-
- let name = defaultName;
- let description = "";
- let tags: string[] = [];
- let body = content;
-
- // Check for TOML frontmatter
- const trimmed = content.trimStart();
- if (trimmed.startsWith(FRONTMATTER_DELIMITER)) {
- const afterOpen = trimmed.slice(FRONTMATTER_DELIMITER.length);
- // Find the closing +++
- const closeIndex = afterOpen.indexOf(FRONTMATTER_DELIMITER);
- if (closeIndex !== -1) {
- const tomlSource = afterOpen.slice(0, closeIndex);
- body = afterOpen.slice(closeIndex + FRONTMATTER_DELIMITER.length);
-
- try {
- const frontmatter = parse(tomlSource);
-
- if (typeof frontmatter.name === "string") {
- name = frontmatter.name;
- }
- if (typeof frontmatter.description === "string") {
- description = frontmatter.description;
- }
- if (Array.isArray(frontmatter.tags)) {
- tags = (frontmatter.tags as unknown[]).filter((t): t is string => typeof t === "string");
- }
- } catch {
- // Malformed TOML — fall through with defaults
- }
- }
- }
-
- // Trim leading newline from body (handles both LF and CRLF)
- body = body.replace(/^\r?\n/, "");
-
- return {
- name,
- description,
- tags,
- content: body,
- scope,
- source: filePath,
- directory,
- };
-}
diff --git a/packages/core/src/tools/bash-arity.ts b/packages/core/src/tools/bash-arity.ts
deleted file mode 100644
index 1aaba8e..0000000
--- a/packages/core/src/tools/bash-arity.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-// Hardcoded dictionary of well-known commands and their arity
-// (number of tokens that form the "human-understandable" prefix)
-const ARITY: Record<string, number> = {
- git: 2, // "git checkout", "git commit", etc.
- npm: 3, // "npm run dev", "npm install -g"
- docker: 2, // "docker compose", "docker build"
- kubectl: 2, // "kubectl get", "kubectl apply"
- bun: 2, // "bun install", "bun test"
- cargo: 2, // "cargo build", "cargo test"
- go: 2, // "go build", "go test"
- python: 2, // "python -m", "python script.py"
- python3: 2,
- pip: 2,
- pip3: 2,
- brew: 2,
- apt: 2,
- "apt-get": 2,
- dnf: 2,
- yum: 2,
- pacman: 2,
- systemctl: 2,
- journalctl: 2,
- ssh: 2,
- scp: 2,
- rsync: 2,
- curl: 2, // "curl -X", "curl https://"
- wget: 2,
- tar: 2, // "tar -xzf", "tar -czf"
- zip: 2,
- unzip: 2,
- chown: 2,
- chmod: 2,
- mount: 2,
- umount: 2,
- // Default: all other commands are arity 1
-};
-
-// Get the normalized prefix for a list of tokens
-export function prefix(tokens: string[]): string[] {
- if (tokens.length === 0) return [];
- const first = tokens[0];
- if (!first) return [];
- const arity = ARITY[first.toLowerCase()];
- if (arity === undefined) {
- return [first];
- }
- return tokens.slice(0, arity);
-}
diff --git a/packages/core/src/tools/key-usage.ts b/packages/core/src/tools/key-usage.ts
deleted file mode 100644
index 0655ad7..0000000
--- a/packages/core/src/tools/key-usage.ts
+++ /dev/null
@@ -1,322 +0,0 @@
-import { z } from "zod";
-import type { ClaudeAccount, ClaudeUsageReport, ClaudeUsageResult } from "../credentials/claude.js";
-import { getAccountUsageWithSource } from "../credentials/claude.js";
-import type { OpencodeUsageReport } from "../credentials/opencode.js";
-import { fetchOpencodeUsage as defaultFetchOpencodeUsage } from "../credentials/opencode.js";
-import type { KeyState, ToolDefinition } from "../types/index.js";
-
-/**
- * Collaborators the `key_usage` tool needs from the API layer (which owns the
- * live `ModelRegistry` and the discovered Claude accounts). The two `fetch*`
- * hooks default to the real credential fetchers but are injectable so tests can
- * exercise the tool without network or DB access.
- */
-export interface KeyUsageCallbacks {
- /** Current key states from the model registry (definition + active/exhausted status). */
- listKeys(): KeyState[];
- /** Discovered Claude accounts, used to resolve `anthropic` keys to credentials. */
- listClaudeAccounts(): ClaudeAccount[];
- /**
- * Fetch an anthropic account's usage with provenance (live vs cache).
- * Defaults to `getAccountUsageWithSource`.
- */
- fetchAnthropicUsage?: (account: ClaudeAccount) => Promise<ClaudeUsageResult | null>;
- /**
- * Fetch an opencode-go key's usage (always a live scrape — OpenCode keeps no
- * local cache). Defaults to `fetchOpencodeUsage`.
- */
- fetchOpencodeUsage?: (keyId: string) => Promise<OpencodeUsageReport | null>;
-}
-
-/** A single normalized usage window (5-hour / week / month). */
-interface UsageWindow {
- label: string;
- /** Remaining headroom as a 0–100 percentage. Omitted when the source gives no utilization. */
- remainingPercent?: number;
- /** Epoch-ms the window resets. Omitted when the source gives no reset time. */
- resetsAt?: number;
-}
-
-/** Fully normalized per-key usage, ready for rendering. */
-interface KeyUsageEntry {
- keyId: string;
- provider: string;
- status: "active" | "exhausted";
- lastError?: string;
- exhaustedAt?: number;
- /** Provenance of the usage figures: a fresh live fetch or a cached payload. */
- dataSource?: "live" | "cache";
- /** Epoch-ms the cached payload was last fetched from source (only on `dataSource: "cache"`). */
- cachedAt?: number;
- windows: UsageWindow[];
- /** Set when no usage figures could be obtained for an otherwise-supported key. */
- unavailableReason?: string;
- /** Set when the provider has no usage-reporting support. */
- unsupported?: boolean;
-}
-
-function clampPercent(value: number): number {
- if (value < 0) return 0;
- if (value > 100) return 100;
- return value;
-}
-
-/** Convert a raw `{ utilization, resetsAt }` bucket into a normalized window. */
-function toWindow(
- label: string,
- bucket?: { utilization?: number; resetsAt?: number },
-): UsageWindow | null {
- if (!bucket) return null;
- const hasUtil = typeof bucket.utilization === "number";
- const hasReset = typeof bucket.resetsAt === "number";
- if (!hasUtil && !hasReset) return null;
- return {
- label,
- ...(hasUtil
- ? { remainingPercent: clampPercent(Math.round((1 - (bucket.utilization as number)) * 100)) }
- : {}),
- ...(hasReset ? { resetsAt: bucket.resetsAt } : {}),
- };
-}
-
-function anthropicWindows(report: ClaudeUsageReport): UsageWindow[] {
- const windows: UsageWindow[] = [];
- const fiveHour = toWindow("5-hour", report.fiveHour);
- if (fiveHour) windows.push(fiveHour);
- const week = toWindow("week", report.sevenDay);
- if (week) windows.push(week);
- return windows;
-}
-
-function opencodeWindows(report: OpencodeUsageReport): UsageWindow[] {
- const windows: UsageWindow[] = [];
- const fiveHour = toWindow("5-hour", report.fiveHour);
- if (fiveHour) windows.push(fiveHour);
- const week = toWindow("week", report.weekly);
- if (week) windows.push(week);
- const month = toWindow("month", report.monthly);
- if (month) windows.push(month);
- return windows;
-}
-
-/**
- * Resolve which Claude account backs an `anthropic` key. Matches by key id or by
- * the account's source file (the key's `credentials_file`), falling back to the
- * first available account — mirrors the existing `/models/key-usage` route.
- */
-function matchAnthropicAccount(
- accounts: ClaudeAccount[],
- keyId: string,
- credFile?: string,
-): ClaudeAccount | undefined {
- const matched = accounts.find(
- (a) => a.id === keyId || (credFile != null && a.source === credFile),
- );
- return matched ?? accounts[0];
-}
-
-function iso(ms: number): string {
- return new Date(ms).toISOString();
-}
-
-/** Human-readable coarse duration, e.g. "3h 12m", "5d 8h", "0m". */
-function formatDuration(ms: number): string {
- const totalSec = Math.round(Math.abs(ms) / 1000);
- const days = Math.floor(totalSec / 86400);
- const hours = Math.floor((totalSec % 86400) / 3600);
- const minutes = Math.floor((totalSec % 3600) / 60);
- const parts: string[] = [];
- if (days > 0) parts.push(`${days}d`);
- if (hours > 0) parts.push(`${hours}h`);
- if (minutes > 0 || parts.length === 0) parts.push(`${minutes}m`);
- return parts.join(" ");
-}
-
-function formatRelative(targetMs: number, nowMs: number): string {
- const delta = targetMs - nowMs;
- return delta >= 0 ? `in ${formatDuration(delta)}` : `${formatDuration(delta)} ago`;
-}
-
-function formatWindow(window: UsageWindow, now: number): string {
- const parts: string[] = [];
- if (typeof window.remainingPercent === "number") {
- parts.push(`${window.remainingPercent}% remaining`);
- }
- if (typeof window.resetsAt === "number") {
- parts.push(`resets ${iso(window.resetsAt)} (${formatRelative(window.resetsAt, now)})`);
- }
- return `${window.label}: ${parts.join(", ")}`;
-}
-
-/**
- * Render normalized usage entries into an AI-friendly text block. Pure — `now`
- * is injected so relative timestamps are deterministic under test.
- */
-export function formatKeyUsage(entries: KeyUsageEntry[], now: number): string {
- if (entries.length === 0) return "No API keys matched.";
-
- const lines: string[] = [];
- lines.push(`API key usage — ${entries.length} key${entries.length === 1 ? "" : "s"}:`);
-
- for (const entry of entries) {
- lines.push("");
- lines.push(`[${entry.keyId}] provider: ${entry.provider}`);
-
- if (entry.status === "exhausted") {
- const since =
- typeof entry.exhaustedAt === "number"
- ? ` (since ${iso(entry.exhaustedAt)}, ${formatRelative(entry.exhaustedAt, now)})`
- : "";
- lines.push(`status: EXHAUSTED${since}`);
- if (entry.lastError) lines.push(`last error: ${entry.lastError}`);
- } else {
- lines.push("status: active");
- }
-
- if (entry.unsupported) {
- lines.push(
- `usage: not supported for provider "${entry.provider}" (only anthropic and opencode-go report usage)`,
- );
- continue;
- }
-
- if (entry.dataSource === "live") {
- lines.push("data: live (fetched just now)");
- } else if (entry.dataSource === "cache") {
- lines.push(
- typeof entry.cachedAt === "number"
- ? `data: cached — last fetched from source ${iso(entry.cachedAt)} (${formatRelative(entry.cachedAt, now)})`
- : "data: cached (source timestamp unknown)",
- );
- }
-
- for (const window of entry.windows) {
- lines.push(formatWindow(window, now));
- }
-
- if (entry.unavailableReason) {
- lines.push(`usage: unavailable — ${entry.unavailableReason}`);
- }
- }
-
- return lines.join("\n");
-}
-
-async function buildEntry(
- key: KeyState,
- accounts: ClaudeAccount[],
- fetchAnthropic: (account: ClaudeAccount) => Promise<ClaudeUsageResult | null>,
- fetchOpencode: (keyId: string) => Promise<OpencodeUsageReport | null>,
-): Promise<KeyUsageEntry> {
- const def = key.definition;
- const entry: KeyUsageEntry = {
- keyId: def.id,
- provider: def.provider,
- status: key.status,
- windows: [],
- ...(key.lastError ? { lastError: key.lastError } : {}),
- ...(typeof key.exhaustedAt === "number" ? { exhaustedAt: key.exhaustedAt } : {}),
- };
-
- if (def.provider === "anthropic") {
- const account = matchAnthropicAccount(accounts, def.id, def.credentials_file);
- if (!account) {
- entry.unavailableReason = "no Claude account credentials available for this key";
- return entry;
- }
- let result: ClaudeUsageResult | null = null;
- try {
- result = await fetchAnthropic(account);
- } catch {
- result = null;
- }
- if (!result) {
- entry.unavailableReason = "no live usage data and no cached usage available";
- return entry;
- }
- entry.dataSource = result.source;
- if (typeof result.cachedAt === "number") entry.cachedAt = result.cachedAt;
- entry.windows = anthropicWindows(result.report);
- if (entry.windows.length === 0) {
- entry.unavailableReason = "usage endpoint returned no window data";
- }
- return entry;
- }
-
- if (def.provider === "opencode-go") {
- let report: OpencodeUsageReport | null = null;
- try {
- report = await fetchOpencode(def.id);
- } catch {
- report = null;
- }
- if (!report) {
- entry.unavailableReason =
- "live usage unavailable (requires OPENCODE_COOKIE and a workspace id, or the source returned no data; OpenCode keeps no local cache)";
- return entry;
- }
- entry.dataSource = "live";
- entry.windows = opencodeWindows(report);
- if (entry.windows.length === 0) {
- entry.unavailableReason = "usage source returned no window data";
- }
- return entry;
- }
-
- entry.unsupported = true;
- return entry;
-}
-
-export function createKeyUsageTool(callbacks: KeyUsageCallbacks): ToolDefinition {
- const fetchAnthropic = callbacks.fetchAnthropicUsage ?? getAccountUsageWithSource;
- const fetchOpencode = callbacks.fetchOpencodeUsage ?? defaultFetchOpencodeUsage;
-
- return {
- name: "key_usage",
- description: [
- "Report current usage levels for configured API keys so you can pick a key with",
- "headroom, warn before hitting a rate limit, or diagnose an exhausted-key failure.",
- "",
- "For each key it returns: provider, active/exhausted status (with the last error when",
- "exhausted), remaining rate-limit headroom per window (5-hour, weekly, and monthly where",
- "the provider exposes it), each window's reset timestamp, and whether the figures are",
- "live or served from cache (with the cache's last-fetched time).",
- "",
- "Pass a key_id to inspect one key; omit it to report all keys. Usage reporting is",
- "supported for anthropic and opencode-go keys.",
- ].join("\n"),
- parameters: z.object({
- key_id: z
- .string()
- .optional()
- .describe(
- 'The id of a single key to report (as configured in dispatch.toml, e.g. "claude-max"). Omit to report all configured keys.',
- ),
- }),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const requestedKeyId = (args.key_id as string | undefined)?.trim() || undefined;
-
- const allKeys = callbacks.listKeys();
- if (allKeys.length === 0) {
- return "No API keys are configured.";
- }
-
- let keys = allKeys;
- if (requestedKeyId) {
- keys = allKeys.filter((k) => k.definition.id === requestedKeyId);
- if (keys.length === 0) {
- const available = allKeys.map((k) => k.definition.id).join(", ");
- return `Error: no key found with id "${requestedKeyId}". Available keys: ${available}.`;
- }
- }
-
- const accounts = callbacks.listClaudeAccounts();
- const entries: KeyUsageEntry[] = [];
- for (const key of keys) {
- entries.push(await buildEntry(key, accounts, fetchAnthropic, fetchOpencode));
- }
-
- return formatKeyUsage(entries, Date.now());
- },
- };
-}
diff --git a/packages/core/src/tools/list-files.ts b/packages/core/src/tools/list-files.ts
deleted file mode 100644
index e003099..0000000
--- a/packages/core/src/tools/list-files.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { readdir } from "node:fs/promises";
-import { z } from "zod";
-import type { ToolDefinition } from "../types/index.js";
-import { canonicalize } from "./path-utils.js";
-
-export function createListFilesTool(workingDirectory: string): ToolDefinition {
- return {
- name: "list_files",
- description: "List files and directories at a path relative to the working directory.",
- parameters: z.object({
- path: z
- .string()
- .optional()
- .describe("Path to list, relative to the working directory. Defaults to '.'"),
- }),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const relPath = (args.path as string | undefined) ?? ".";
- // Canonicalize so a symlink-in-workdir pointing outside is detected.
- // See `canonicalize` in ./path-utils.ts for the resolution semantics.
- const absolutePath = await canonicalize(workingDirectory, relPath);
- const absoluteWorkDir = await canonicalize(workingDirectory);
-
- if (absolutePath !== absoluteWorkDir && !absolutePath.startsWith(`${absoluteWorkDir}/`)) {
- return `Error: Path "${relPath}" is outside the working directory.`;
- }
-
- try {
- const entries = await readdir(absolutePath, { withFileTypes: true });
- if (entries.length === 0) {
- return "(empty directory)";
- }
- return entries
- .map((entry) => (entry.isDirectory() ? `${entry.name}/` : entry.name))
- .join("\n");
- } catch (err) {
- return `Error listing files: ${err instanceof Error ? err.message : String(err)}`;
- }
- },
- };
-}
diff --git a/packages/core/src/tools/lsp.ts b/packages/core/src/tools/lsp.ts
deleted file mode 100644
index 3842cd4..0000000
--- a/packages/core/src/tools/lsp.ts
+++ /dev/null
@@ -1,135 +0,0 @@
-import { isAbsolute, resolve } from "node:path";
-import { pathToFileURL } from "node:url";
-import { z } from "zod";
-import { report as reportDiagnostics } from "../lsp/diagnostic.js";
-import type { LspManager } from "../lsp/manager.js";
-import type { ResolvedLspServer } from "../lsp/server.js";
-import type { ToolDefinition } from "../types/index.js";
-
-const OPERATIONS = ["diagnostics", "hover", "definition", "references", "documentSymbol"] as const;
-type Operation = (typeof OPERATIONS)[number];
-
-/**
- * Context the LSP tool needs from the host: the live manager, the tab's
- * effective working directory (used as the LSP `root`), and the servers
- * resolved from that directory's `dispatch.toml`.
- */
-export interface LspToolContext {
- manager: LspManager;
- workingDirectory: string;
- servers: ResolvedLspServer[];
-}
-
-/**
- * On-demand LSP query tool. Exposes diagnostics plus the navigation
- * capabilities (hover/definition/references/documentSymbol) for a file at a
- * position. Gated behind `perm_lsp` by the host.
- *
- * Coordinates are **1-based** in this tool's API (editor-style, matching what
- * `read_file` shows); they are converted to the LSP wire's 0-based positions
- * before the request.
- */
-export function createLspTool(getContext: () => LspToolContext): ToolDefinition {
- return {
- name: "lsp",
- description:
- "Query the configured Language Server (e.g. luau-lsp for Roblox Luau) about a file. " +
- "Operations: 'diagnostics' (type/lint errors for a file), 'hover' (type/docs at a position), " +
- "'definition' (where a symbol is defined), 'references' (all uses of a symbol), " +
- "'documentSymbol' (outline of a file). Line and character are 1-based (as shown in editors). " +
- "Returns JSON. Requires an [lsp] server configured in dispatch.toml that matches the file's extension.",
- parameters: z.object({
- operation: z.enum(OPERATIONS).describe("The LSP operation to perform"),
- path: z.string().describe("Path to the file, relative to the working directory"),
- line: z
- .number()
- .int()
- .min(1)
- .optional()
- .describe(
- "Line number, 1-based (as shown in editors). Required for hover/definition/references.",
- ),
- character: z
- .number()
- .int()
- .min(1)
- .optional()
- .describe(
- "Character/column, 1-based (as shown in editors). Required for hover/definition/references.",
- ),
- }),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const { manager, workingDirectory, servers } = getContext();
- const operation = args.operation as Operation;
- const pathArg = typeof args.path === "string" ? args.path : "";
- if (!pathArg) return "Error: 'path' is required.";
-
- const file = isAbsolute(pathArg) ? pathArg : resolve(workingDirectory, pathArg);
-
- if (servers.length === 0) {
- return "Error: no LSP servers are configured. Add an [lsp] entry to dispatch.toml.";
- }
- if (!manager.hasServerForFile(file, servers)) {
- return `Error: no configured LSP server matches "${pathArg}" (check the server's extensions in dispatch.toml).`;
- }
-
- // Sync the file so the server has current content, then act.
- await manager.touchFile({ file, root: workingDirectory, servers, mode: "document" });
-
- if (operation === "diagnostics") {
- const all = manager.getDiagnostics({ root: workingDirectory, servers, file });
- const block = reportDiagnostics(file, all[file] ?? []);
- return block || `No errors reported for ${pathArg}.`;
- }
-
- if (operation === "documentSymbol") {
- const uri = pathToFileURL(file).href;
- const results = await manager.request({
- file,
- root: workingDirectory,
- servers,
- method: "textDocument/documentSymbol",
- params: { textDocument: { uri } },
- });
- const flat = results.flat().filter(Boolean);
- return flat.length === 0
- ? `No symbols found in ${pathArg}.`
- : JSON.stringify(flat, null, 2);
- }
-
- // Positional operations need line + character.
- const line = typeof args.line === "number" ? Math.floor(args.line) : undefined;
- const character = typeof args.character === "number" ? Math.floor(args.character) : undefined;
- if (line === undefined || character === undefined) {
- return `Error: '${operation}' requires both 'line' and 'character' (1-based).`;
- }
-
- const uri = pathToFileURL(file).href;
- // Convert editor 1-based → LSP wire 0-based.
- const position = { line: line - 1, character: character - 1 };
- const method =
- operation === "hover"
- ? "textDocument/hover"
- : operation === "definition"
- ? "textDocument/definition"
- : "textDocument/references";
- const params: Record<string, unknown> = {
- textDocument: { uri },
- position,
- ...(operation === "references" ? { context: { includeDeclaration: true } } : {}),
- };
-
- const results = await manager.request({
- file,
- root: workingDirectory,
- servers,
- method,
- params,
- });
- const flat = results.flat().filter(Boolean);
- return flat.length === 0
- ? `No results found for ${operation} at ${pathArg}:${line}:${character}.`
- : JSON.stringify(flat, null, 2);
- },
- };
-}
diff --git a/packages/core/src/tools/path-utils.ts b/packages/core/src/tools/path-utils.ts
deleted file mode 100644
index 3bba0d3..0000000
--- a/packages/core/src/tools/path-utils.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import { realpath } from "node:fs/promises";
-import { basename, dirname, join, resolve } from "node:path";
-
-/**
- * Resolve a path to its canonical absolute form, following symlinks at
- * every level.
- *
- * When the leaf does not exist (common for `write_file` creating a new
- * file), walks up to the nearest existing ancestor, canonicalizes that,
- * then re-appends the missing trailing segments — ensuring a symlink in
- * the *middle* of the path is still resolved. Without this, a
- * `workdir/escape-link/new-file.txt` write where `escape-link` points
- * outside the workdir would slip the containment check.
- *
- * Used everywhere we compare a user-supplied path against a trusted
- * root (workdir, SPILL_ROOT). Resolving symlinks consistently is the
- * only reliable way to detect a symlink-in-workdir-pointing-outside
- * escape; lexical-only checks let those through.
- *
- * Argument semantics match `path.resolve(...paths)`: later absolute
- * segments override earlier ones, relative segments are joined.
- */
-export async function canonicalize(...paths: string[]): Promise<string> {
- const lexical = resolve(...paths);
-
- // Fast path: full path exists, realpath resolves all symlinks.
- try {
- return await realpath(lexical);
- } catch {
- // Path doesn't exist — fall through to ancestor walk.
- }
-
- // Walk up until we hit an existing ancestor we can realpath, then
- // re-append the missing trailing segments. This handles cases like
- // write_file creating /workdir/symlink/new/dir/file.txt where the
- // "symlink" segment exists but the rest doesn't.
- let current = lexical;
- const trailing: string[] = [];
- while (true) {
- const parent = dirname(current);
- if (parent === current) break; // hit filesystem root
- trailing.unshift(basename(current));
- try {
- const realParent = await realpath(parent);
- return join(realParent, ...trailing);
- } catch {
- current = parent;
- }
- }
-
- // No existing ancestor (pathological — e.g. the entire mount is gone).
- // Return the lexical path; downstream containment checks will still
- // catch obvious escapes via `..` etc.
- return lexical;
-}
diff --git a/packages/core/src/tools/read-file-slice.ts b/packages/core/src/tools/read-file-slice.ts
deleted file mode 100644
index 0f83bcb..0000000
--- a/packages/core/src/tools/read-file-slice.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-import { readFile } from "node:fs/promises";
-import { z } from "zod";
-import type { ToolDefinition } from "../types/index.js";
-import { canonicalize } from "./path-utils.js";
-import { SPILL_ROOT } from "./truncate.js";
-
-const DEFAULT_LENGTH = 5000;
-
-export function createReadFileSliceTool(workingDirectory: string): ToolDefinition {
- return {
- name: "read_file_slice",
- description:
- "Read a character-range slice of a single line in a file. Use this when read_file returns a truncated line marker like '[line N truncated, total X chars; use read_file_slice ...]', or when you need precise byte-ish access into a minified file (huge JSON, base64 blob, etc.). For normal line-oriented reading use read_file with offset/limit instead.",
- parameters: z.object({
- path: z.string().describe("Path to the file, relative to the working directory."),
- line: z.number().int().min(1).describe("1-indexed line number to slice into."),
- charOffset: z
- .number()
- .int()
- .min(0)
- .optional()
- .describe("0-indexed character offset within the line. Default: 0 (start of line)."),
- charLength: z
- .number()
- .int()
- .min(1)
- .optional()
- .describe(
- `Max characters to return from the slice. Default: ${DEFAULT_LENGTH}. The universal tool-output truncator will still spill if the result is huge.`,
- ),
- }),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const filePath = args.path as string;
- const lineNumber = args.line as number;
- const charOffset =
- typeof args.charOffset === "number" ? Math.max(0, Math.floor(args.charOffset)) : 0;
- const charLength =
- typeof args.charLength === "number"
- ? Math.max(1, Math.floor(args.charLength))
- : DEFAULT_LENGTH;
-
- // Canonicalize all three so symlink-in-workdir escapes are detected.
- // See `canonicalize` in ./path-utils.ts for the resolution semantics.
- const absolutePath = await canonicalize(workingDirectory, filePath);
- const absoluteWorkDir = await canonicalize(workingDirectory);
- const absoluteSpillRoot = await canonicalize(SPILL_ROOT);
- const isUnderWorkdir =
- absolutePath === absoluteWorkDir || absolutePath.startsWith(`${absoluteWorkDir}/`);
- const isSpillFile =
- absolutePath === absoluteSpillRoot || absolutePath.startsWith(`${absoluteSpillRoot}/`);
-
- if (!isUnderWorkdir && !isSpillFile) {
- return `Error: Path "${filePath}" is outside the working directory.`;
- }
-
- let raw: string;
- try {
- raw = await readFile(absolutePath, "utf8");
- } catch (err) {
- const code = (err as NodeJS.ErrnoException).code;
- if (code === "ENOENT") {
- return `Error: File "${filePath}" not found.`;
- }
- return `Error reading file: ${err instanceof Error ? err.message : String(err)}`;
- }
-
- const lines = raw.split("\n");
- const trailingNewline = raw.endsWith("\n");
- const totalLines = trailingNewline ? lines.length - 1 : lines.length;
-
- if (lineNumber > totalLines) {
- return `Error: line ${lineNumber} exceeds file length (${totalLines} lines).`;
- }
-
- const line = lines[lineNumber - 1] ?? "";
- const lineLength = line.length;
-
- if (charOffset >= lineLength) {
- return `Error: charOffset ${charOffset} exceeds line length (${lineLength} chars).`;
- }
-
- const sliceEnd = Math.min(charOffset + charLength, lineLength);
- const slice = line.slice(charOffset, sliceEnd);
- const remaining = lineLength - sliceEnd;
-
- const header = `[file: ${filePath} — line ${lineNumber}, chars ${charOffset}-${sliceEnd} of ${lineLength}${remaining > 0 ? ` (${remaining.toLocaleString()} chars remain after this slice)` : ""}]`;
- return `${header}\n${slice}`;
- },
- };
-}
diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts
deleted file mode 100644
index fa82dce..0000000
--- a/packages/core/src/tools/read-file.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-import { readFile } from "node:fs/promises";
-import { z } from "zod";
-import type { ToolDefinition } from "../types/index.js";
-import { canonicalize } from "./path-utils.js";
-import { MAX_LINES, SPILL_ROOT } from "./truncate.js";
-
-// Per-line truncation: any single line longer than MAX_LINE_CHARS is cut and
-// replaced with a marker indicating the total length. This protects against
-// minified files / base64 blobs / massive single-line JSON. The AI can use
-// `read_file_slice` to inspect a specific char range within a long line.
-const MAX_LINE_CHARS = 2000;
-
-// Aligned with the universal truncator's MAX_LINES so a default-args read
-// returns a response that fits under the truncator's line ceiling. Without
-// this alignment, every default read of a >500-line file got returned by
-// the tool and then immediately spilled by the truncator — wasted work.
-// Char-dense files can still spill via MAX_CHARS, but that's content-
-// dependent rather than guaranteed.
-const DEFAULT_LIMIT = MAX_LINES;
-// Hard cap on lines per request even if `limit` is larger or omitted.
-// Prevents a `read_file(huge.log)` with no params from returning a million
-// lines. The universal truncator at the agent level will spill anything
-// over its own threshold, but this is a tighter first line of defense
-// scoped to the read tool itself.
-const HARD_LIMIT = 5000;
-
-export function createReadFileTool(workingDirectory: string): ToolDefinition {
- return {
- name: "read_file",
- description:
- "Read a file relative to the working directory. Returns up to `limit` lines starting at line `offset` (1-indexed). Lines longer than 2000 chars are truncated mid-line with a marker showing the total length — use the `read_file_slice` tool to read a specific char range within a long line. If the response is still too large, the dispatch tool-output truncator may spill the full content to /tmp/dispatch/tool-results/.",
- parameters: z.object({
- path: z.string().describe("Path to the file, relative to the working directory"),
- offset: z
- .number()
- .int()
- .min(1)
- .optional()
- .describe("1-indexed start line. Default: 1 (start of file)."),
- limit: z
- .number()
- .int()
- .min(1)
- .optional()
- .describe(
- `Max lines to return. Default: ${DEFAULT_LIMIT}. Hard cap: ${HARD_LIMIT}. Use a small limit when exploring a large file.`,
- ),
- }),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const filePath = args.path as string;
- const offset = typeof args.offset === "number" ? Math.max(1, Math.floor(args.offset)) : 1;
- const requestedLimit =
- typeof args.limit === "number" ? Math.max(1, Math.floor(args.limit)) : DEFAULT_LIMIT;
- const limit = Math.min(requestedLimit, HARD_LIMIT);
-
- // Canonicalize all three so symlink-in-workdir escapes are detected:
- // a workdir-relative path that resolves through symlinks to /etc must
- // fail the containment check below.
- const absolutePath = await canonicalize(workingDirectory, filePath);
- const absoluteWorkDir = await canonicalize(workingDirectory);
- const absoluteSpillRoot = await canonicalize(SPILL_ROOT);
- const isUnderWorkdir =
- absolutePath === absoluteWorkDir || absolutePath.startsWith(`${absoluteWorkDir}/`);
- const isSpillFile =
- absolutePath === absoluteSpillRoot || absolutePath.startsWith(`${absoluteSpillRoot}/`);
-
- if (!isUnderWorkdir && !isSpillFile) {
- return `Error: Path "${filePath}" is outside the working directory.`;
- }
-
- let raw: string;
- try {
- raw = await readFile(absolutePath, "utf8");
- } catch (err) {
- const code = (err as NodeJS.ErrnoException).code;
- if (code === "ENOENT") {
- return `Error: File "${filePath}" not found.`;
- }
- return `Error reading file: ${err instanceof Error ? err.message : String(err)}`;
- }
-
- // A truly empty file (0 bytes) would otherwise slip through the
- // line-counting below: `"".split("\n")` is `[""]` and there's no
- // trailing newline, yielding a spurious `totalLines === 1`.
- if (raw === "") {
- return `(empty file: ${filePath})`;
- }
-
- const allLines = raw.split("\n");
- // `split("\n")` produces an extra empty entry when the file ends with a
- // newline. The total line count we report to the caller should match
- // the human-visible line count (lines that have content or terminate
- // with \n).
- const trailingNewline = raw.endsWith("\n");
- const totalLines = trailingNewline ? allLines.length - 1 : allLines.length;
-
- if (totalLines === 0) {
- return `(empty file: ${filePath})`;
- }
-
- if (offset > totalLines) {
- return `Error: offset ${offset} exceeds file length (${totalLines} lines).`;
- }
-
- const startIdx = offset - 1; // 0-indexed
- const endIdx = Math.min(startIdx + limit, totalLines);
- const slice = allLines.slice(startIdx, endIdx);
-
- // Apply per-line truncation. We tag truncated lines with the line
- // number and total chars so the AI knows how to call read_file_slice.
- const rendered: string[] = [];
- for (let i = 0; i < slice.length; i++) {
- const lineNumber = startIdx + i + 1;
- const line = slice[i] ?? "";
- if (line.length > MAX_LINE_CHARS) {
- const visible = line.slice(0, MAX_LINE_CHARS);
- rendered.push(
- `${visible}...[line ${lineNumber} truncated, total ${line.length.toLocaleString()} chars; use read_file_slice with path="${filePath}" line=${lineNumber} to read more]`,
- );
- } else {
- rendered.push(line);
- }
- }
-
- const header = `[file: ${filePath} — lines ${offset}-${endIdx} of ${totalLines}]`;
- return `${header}\n${rendered.join("\n")}`;
- },
- };
-}
diff --git a/packages/core/src/tools/read-tab.ts b/packages/core/src/tools/read-tab.ts
deleted file mode 100644
index e80dbd0..0000000
--- a/packages/core/src/tools/read-tab.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-import { z } from "zod";
-import type { AgentStatus, ToolDefinition } from "../types/index.js";
-import type { TabResolution } from "./send-to-tab.js";
-
-export interface ReadTabCallbacks {
- /** Resolve a (possibly short) handle to one open tab. */
- resolveShortId(prefix: string): TabResolution;
- /**
- * Return the target tab's most recent COMPLETED assistant turn as plain
- * text, plus its current status. `text` is null when the tab has no
- * completed assistant turn yet.
- */
- getLastResponse(tabId: string): { text: string | null; status: AgentStatus };
- /** Snapshot of currently-open tabs, for "available tabs" error hints. */
- listOpenHandles(): Array<{ handle: string; title: string }>;
-}
-
-/** Render the "available tabs" hint shared by the none/ambiguous branches. */
-function renderOpenHandles(handles: Array<{ handle: string; title: string }>): string {
- if (handles.length === 0) return "No other tabs are currently open.";
- const lines = handles.map((h) => ` - ${h.handle}: ${h.title}`);
- return ["Currently open tabs:", ...lines].join("\n");
-}
-
-export function createReadTabTool(callbacks: ReadTabCallbacks): ToolDefinition {
- return {
- name: "read_tab",
- description: [
- "Read the most recent completed response from another tab (agent) by its short ID.",
- "",
- "Returns a SNAPSHOT — it does NOT block or wait for the target to finish.",
- " - If the target is idle, you get its just-finished turn.",
- " - If the target is still running, you get its PREVIOUS completed turn (if any);",
- " call read_tab again later to get the newest one.",
- "",
- "Use this after send_to_tab to collect another agent's reply. IDs are git-style",
- "prefixes: pass any length that uniquely identifies the target (min 4 chars).",
- ].join("\n"),
- parameters: z.object({
- tab_id: z
- .string()
- .describe(
- "The short ID (handle) of the tab to read, as shown in the tab bar. Any unique-length prefix works (min 4 chars).",
- ),
- }),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const rawId = (args.tab_id as string | undefined)?.trim() ?? "";
-
- if (!rawId) {
- return `Error: tab_id is required.\n\n${renderOpenHandles(callbacks.listOpenHandles())}`;
- }
-
- const resolution = callbacks.resolveShortId(rawId);
-
- if (resolution.status === "none") {
- return [
- `Error: no open tab matches the ID "${rawId}".`,
- "",
- renderOpenHandles(callbacks.listOpenHandles()),
- ].join("\n");
- }
- if (resolution.status === "ambiguous") {
- const matches = resolution.matches.map((m) => ` - ${m.handle}: ${m.title}`).join("\n");
- return [
- `Error: the ID "${rawId}" is ambiguous — it matches multiple open tabs:`,
- matches,
- "",
- "Add one or more characters to disambiguate.",
- ].join("\n");
- }
-
- const target = resolution.tab;
- const { text, status } = callbacks.getLastResponse(target.id);
-
- const runningNote =
- status === "running"
- ? " (this tab is still running; the response below is its previous completed turn — read again later for the newest)"
- : "";
-
- if (text === null) {
- const reason =
- status === "running"
- ? "it is still working on its first turn"
- : "it has no assistant responses yet";
- return `Tab ${target.handle} (${target.title}) has no completed response — ${reason}.`;
- }
-
- return [
- `<tab_response tab="${target.handle}" status="${status}"${runningNote}>`,
- text,
- "</tab_response>",
- ].join("\n");
- },
- };
-}
diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts
deleted file mode 100644
index ff6f4d1..0000000
--- a/packages/core/src/tools/registry.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import type { Tool } from "ai";
-import { jsonSchema, tool } from "ai";
-import { zodToJsonSchema } from "zod-to-json-schema";
-import type { ToolDefinition } from "../types/index.js";
-
-/**
- * Strip JSON Schema fields that Anthropic's API does not accept from a
- * `zodToJsonSchema()` output. The Anthropic `/messages` API rejects (or
- * silently ignores) tools whose `input_schema` contains `$schema`,
- * `additionalProperties`, `default`, or `nullable` — when this happens
- * Claude never sees the tool and the model "thinks forever" instead of
- * calling it.
- *
- * The stripped fields are also harmless to remove for OpenAI-compatible
- * endpoints, so we apply this unconditionally.
- */
-function normalizeForAnthropic(schema: Record<string, unknown>): Record<string, unknown> {
- delete schema.$schema;
- delete schema.additionalProperties;
- delete schema.default;
- delete schema.nullable;
-
- const properties = schema.properties;
- if (properties && typeof properties === "object") {
- for (const key of Object.keys(properties as Record<string, unknown>)) {
- const prop = (properties as Record<string, unknown>)[key];
- if (prop && typeof prop === "object") {
- normalizeForAnthropic(prop as Record<string, unknown>);
- }
- }
- }
-
- const items = schema.items;
- if (items && typeof items === "object") {
- normalizeForAnthropic(items as Record<string, unknown>);
- }
-
- return schema;
-}
-
-/**
- * Convert an internal `ToolDefinition` (Zod-parameterised) to an AI SDK v6
- * `Tool` object.
- *
- * Critically, NO `execute` function is attached. The agent's manual tool
- * loop (see agent.ts) handles execution itself — for permission prompts,
- * shell-output streaming, and queued-message injection. Without `execute`,
- * the SDK never auto-runs tools; it only surfaces `tool-call` events from
- * `fullStream` that agent.ts collects and dispatches.
- */
-function toAISDKTool(def: ToolDefinition): Tool {
- const raw = zodToJsonSchema(def.parameters) as Record<string, unknown>;
- const normalized = normalizeForAnthropic(raw);
- return tool({
- description: def.description,
- inputSchema: jsonSchema(normalized),
- });
-}
-
-export function createToolRegistry(tools: ToolDefinition[]) {
- const toolMap = new Map<string, ToolDefinition>(tools.map((t) => [t.name, t]));
-
- return {
- getTools(): ToolDefinition[] {
- return [...toolMap.values()];
- },
-
- getTool(name: string): ToolDefinition | undefined {
- return toolMap.get(name);
- },
-
- /**
- * Returns AI SDK v6 `Tool` objects keyed by tool name, for passing
- * directly to `streamText({ tools })`.
- *
- * Each tool has:
- * - `description` — forwarded verbatim from the internal definition.
- * - `inputSchema` — Zod schema converted to JSONSchema7 via
- * `zod-to-json-schema`, then wrapped with the v6
- * `jsonSchema()` helper.
- * - NO `execute` — intentional; see `toAISDKTool` above.
- */
- getAISDKTools(): Record<string, Tool> {
- const result: Record<string, Tool> = {};
- for (const [name, def] of toolMap) {
- result[name] = toAISDKTool(def);
- }
- return result;
- },
- };
-}
diff --git a/packages/core/src/tools/retrieve.ts b/packages/core/src/tools/retrieve.ts
deleted file mode 100644
index 80c3715..0000000
--- a/packages/core/src/tools/retrieve.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-import { z } from "zod";
-import type { ToolDefinition, ToolExecuteContext } from "../types/index.js";
-
-export interface RetrieveCallbacks {
- getResult(
- agentId: string,
- ): Promise<{ status: "done"; result: string } | { status: "error"; error: string }>;
-}
-
-export function createRetrieveTool(callbacks: RetrieveCallbacks): ToolDefinition {
- return {
- name: "retrieve",
- description: [
- "Wait for a child agent or backgrounded shell command to finish and retrieve its result. This tool BLOCKS until completion.",
- "",
- "Pass the ID returned by summon (agent_id) or by an interrupted run_shell (job_id). Once it finishes, the output is returned.",
- "If an error occurred, the error message is returned instead.",
- "",
- "Typical usage:",
- ' 1. summon({ task: "...", tools: [...] }) -> get agent_id',
- " 2. ... do other work or summon more agents ...",
- ' 3. retrieve({ agent_id: "..." }) -> blocks until done, returns result',
- "",
- "Also used for backgrounded shell commands:",
- " If run_shell is interrupted by a user message, it returns a job_id (run_shell_...).",
- ' Use retrieve({ agent_id: "run_shell_..." }) to get the final output when ready.',
- ].join("\n"),
- parameters: z.object({
- agent_id: z.string().describe("The agent_id returned by a previous summon call."),
- }),
- execute: async (
- args: Record<string, unknown>,
- context?: ToolExecuteContext,
- ): Promise<string> => {
- const agentId = args.agent_id as string;
- const queueCallbacks = context?.queueCallbacks;
-
- try {
- let outcome: { status: "done"; result: string } | { status: "error"; error: string };
-
- if (queueCallbacks) {
- const childPromise = callbacks.getResult(agentId);
- const { promise: queuePromise, cancel: cancelQueueWait } =
- queueCallbacks.waitForQueuedMessage();
- const queueSignal = queuePromise.then(() => "QUEUE_INTERRUPT" as const);
-
- const raceResult = await Promise.race([childPromise, queueSignal]);
-
- if (raceResult === "QUEUE_INTERRUPT") {
- const queuedMsgs = queueCallbacks.dequeueMessages();
- const userMessages = queuedMsgs.map((m) => m.message).join("\n---\n");
- return `The subagent (agent_id: ${agentId}) has not completed its task yet. You will need to call retrieve with this agent_id again later to get the result.\n\n[USER INTERRUPT]\nThe user has sent you message(s) while you were working. You MUST address these before continuing with your current task:\n\n${userMessages}`;
- }
-
- // Child finished first — clean up the queue listener
- cancelQueueWait();
- outcome = raceResult;
- } else {
- outcome = await callbacks.getResult(agentId);
- }
-
- if (outcome.status === "done") {
- return ["<agent_result>", outcome.result, "</agent_result>"].join("\n");
- }
- return `Agent error: ${outcome.error}`;
- } catch (err) {
- return `Error retrieving result: ${err instanceof Error ? err.message : String(err)}`;
- }
- },
- };
-}
diff --git a/packages/core/src/tools/run-shell.ts b/packages/core/src/tools/run-shell.ts
deleted file mode 100644
index ec2db9c..0000000
--- a/packages/core/src/tools/run-shell.ts
+++ /dev/null
@@ -1,176 +0,0 @@
-import { spawn } from "node:child_process";
-import { randomUUID } from "node:crypto";
-import { z } from "zod";
-import type { ToolDefinition, ToolExecuteContext } from "../types/index.js";
-
-const DEFAULT_TIMEOUT = 2 * 60 * 1000; // 2 minutes
-
-export interface BackgroundShellJob {
- command: string;
- stdout: string;
- stderr: string;
- /** Resolves when the process exits */
- completion: Promise<{ stdout: string; stderr: string; exitCode: number; error?: string }>;
-}
-
-/** Shared store for shell commands that were backgrounded due to user interrupt */
-export class BackgroundShellStore {
- private jobs = new Map<string, BackgroundShellJob>();
-
- register(job: BackgroundShellJob): string {
- const id = `run_shell_${randomUUID()}`;
- this.jobs.set(id, job);
- // Auto-cleanup after completion + 10 minutes
- job.completion.finally(() => {
- setTimeout(() => this.jobs.delete(id), 10 * 60 * 1000);
- });
- return id;
- }
-
- async getResult(
- id: string,
- ): Promise<{ status: "done"; result: string } | { status: "error"; error: string }> {
- const job = this.jobs.get(id);
- if (!job) {
- return { status: "error", error: `No background shell job found with id '${id}'` };
- }
- const result = await job.completion;
- return { status: "done", result: JSON.stringify(result) };
- }
-
- has(id: string): boolean {
- return this.jobs.has(id);
- }
-}
-
-export function createRunShellTool(
- workingDirectory: string,
- shellStore?: BackgroundShellStore,
-): ToolDefinition {
- return {
- name: "run_shell",
- description:
- "Execute a shell command in the working directory. Returns stdout, stderr, and exit code. Use for running tests, builds, git operations, package management, and other development tasks. If the user interrupts while a command is running, the command continues in the background and you receive a job ID. Use the retrieve tool with that ID to get the result later.",
- parameters: z.object({
- command: z.string().describe("The shell command to execute"),
- timeout: z.number().optional().describe("Timeout in milliseconds (default 2 minutes)"),
- background: z
- .boolean()
- .optional()
- .describe(
- "If true, the command starts in the background and a job_id is returned immediately. Use the retrieve tool with the job_id to get the result later.",
- ),
- }),
- execute: async (
- args: Record<string, unknown>,
- context?: ToolExecuteContext,
- ): Promise<string> => {
- const command = args.command as string;
- const timeout = (args.timeout as number | undefined) ?? DEFAULT_TIMEOUT;
- const background = (args.background as boolean | undefined) ?? false;
-
- const [shell, shellArgs] = getShell();
- const child = spawn(shell, [...shellArgs, command], {
- cwd: workingDirectory,
- env: process.env,
- timeout,
- stdio: ["ignore", "pipe", "pipe"],
- });
-
- let stdout = "";
- let stderr = "";
-
- const completionPromise = new Promise<{
- stdout: string;
- stderr: string;
- exitCode: number;
- error?: string;
- }>((resolve) => {
- child.stdout?.on("data", (data: Buffer) => {
- const chunk = data.toString();
- stdout += chunk;
- context?.onOutput?.(chunk, "stdout");
- });
- child.stderr?.on("data", (data: Buffer) => {
- const chunk = data.toString();
- stderr += chunk;
- context?.onOutput?.(chunk, "stderr");
- });
-
- child.on("close", (exitCode) => {
- resolve({ stdout, stderr, exitCode: exitCode ?? 1 });
- });
-
- child.on("error", (err) => {
- resolve({ stdout, stderr, exitCode: 1, error: err.message });
- });
- });
-
- // If background mode requested, register immediately and return job ID
- if (background && shellStore) {
- const jobId = shellStore.register({
- command,
- stdout,
- stderr,
- completion: completionPromise,
- });
- return [
- `Command started in background.`,
- `job_id: ${jobId}`,
- `command: ${command}`,
- ``,
- `Use the retrieve tool with this job_id to get the result when ready.`,
- ].join("\n");
- }
-
- const queueCallbacks = context?.queueCallbacks;
-
- if (queueCallbacks && shellStore) {
- const { promise: queuePromise, cancel: cancelQueueWait } =
- queueCallbacks.waitForQueuedMessage();
- const queueSignal = queuePromise.then(() => "QUEUE_INTERRUPT" as const);
-
- const raceResult = await Promise.race([completionPromise, queueSignal]);
-
- if (raceResult === "QUEUE_INTERRUPT") {
- // Background the still-running process
- const jobId = shellStore.register({
- command,
- stdout,
- stderr,
- completion: completionPromise,
- });
-
- const queuedMsgs = queueCallbacks.dequeueMessages();
- const userMessages = queuedMsgs.map((m) => m.message).join("\n---\n");
-
- return [
- `Command backgrounded — still running.`,
- `job_id: ${jobId}`,
- `command: ${command}`,
- `stdout so far: ${stdout.slice(-500) || "(none)"}`,
- `stderr so far: ${stderr.slice(-500) || "(none)"}`,
- ``,
- `Use the retrieve tool with this job_id to get the final result when ready.`,
- ``,
- `[USER INTERRUPT]`,
- `The user has sent you message(s) while you were working. You MUST address these before continuing with your current task:`,
- ``,
- userMessages,
- ].join("\n");
- }
-
- // Command finished before interrupt — clean up queue listener
- cancelQueueWait();
- return JSON.stringify(raceResult);
- }
-
- const result = await completionPromise;
- return JSON.stringify(result);
- },
- };
-}
-
-function getShell(): [string, string[]] {
- return process.platform === "win32" ? ["powershell", ["-Command"]] : ["bash", ["-c"]];
-}
diff --git a/packages/core/src/tools/search-code.ts b/packages/core/src/tools/search-code.ts
deleted file mode 100644
index 4350f6a..0000000
--- a/packages/core/src/tools/search-code.ts
+++ /dev/null
@@ -1,362 +0,0 @@
-import { spawn } from "node:child_process";
-import { stat } from "node:fs/promises";
-import { relative, sep } from "node:path";
-import { z } from "zod";
-import type { ToolDefinition } from "../types/index.js";
-import { canonicalize } from "./path-utils.js";
-
-// Resolve the `cs` binary: an explicit override wins, otherwise rely on PATH.
-// The deployed images build a patched, statically-linked `cs` into
-// /usr/local/bin/cs (see Dockerfile); local dev can point DISPATCH_CS_BIN at a
-// custom build. Read at call time so the environment can change at runtime
-// (and so tests can point it at a stub or temp build).
-function resolveCsBin(): string {
- return process.env.DISPATCH_CS_BIN || "cs";
-}
-
-const DEFAULT_RESULT_LIMIT = 20;
-const MAX_RESULT_LIMIT = 100;
-const MAX_CONTEXT = 20;
-const MIN_SNIPPET_LENGTH = 50;
-const MAX_SNIPPET_LENGTH = 2000;
-const TIMEOUT_MS = 30_000;
-// Hard cap on any single rendered snippet line. Mirrors read-file.ts so a
-// matched minified/generated line (e.g. a 2 MB bundle line) can't blow up the
-// payload. The universal truncator bounds total output; this bounds per-line.
-const MAX_LINE_CHARS = 500;
-
-/** Maps the `only` enum to the corresponding cs flag. */
-const ONLY_FLAGS: Record<string, string> = {
- code: "--only-code",
- comments: "--only-comments",
- strings: "--only-strings",
- declarations: "--only-declarations",
- usages: "--only-usages",
-};
-
-/** One line within a cs JSON match result. */
-interface CsLine {
- line_number: number;
- content: string;
- match_positions?: Array<[number, number]>;
-}
-
-/** A single file result in cs `-f json` output. */
-interface CsResult {
- filename: string;
- location: string;
- score: number;
- /** Present in "lines"/"grep" snippet modes. */
- lines?: CsLine[];
- /**
- * Present instead of `lines` in cs's "snippet" mode (the default "auto"
- * mode selects it for prose). We force a lines-based mode (see buildFlags),
- * but this is kept as a defensive fallback so a content-shape result is
- * still rendered rather than shown as a bare header.
- */
- content?: string;
- matchlocations?: Array<[number, number]>;
- language?: string;
- total_lines?: number;
-}
-
-export function createSearchCodeTool(workingDirectory: string): ToolDefinition {
- return {
- name: "search_code",
- description:
- "Search the codebase by query using `cs` (code spelunker) — a fast, relevance-ranked code search engine. " +
- "Prefer this over grep/find for EXPLORATORY 'where is X / how does Y work' searches: it ranks the most " +
- "relevant files first and returns matching snippets with line numbers, so you spend fewer turns and tokens. " +
- "It respects .gitignore and skips hidden/binary files. " +
- 'Query syntax: space-separated terms are AND\'d; supports OR, NOT, "exact phrases", fuzzy~1, /regex/, and ' +
- "metadata filters like lang:Go, file:test, path:src. " +
- "It is a ranked text search, NOT a semantic/LSP index: it won't resolve types or imports. For an EXHAUSTIVE " +
- "list of every exact match (e.g. before a rename), use run_shell with ripgrep (rg) instead.",
- parameters: z.object({
- query: z
- .string()
- .describe(
- 'The search query. Terms are AND\'d by default. Supports OR, NOT, "phrases", fuzzy~1, /regex/, and filters like lang:Go, file:test, path:src.',
- ),
- path: z
- .string()
- .optional()
- .describe(
- "Subdirectory to scope the search to, relative to the working directory. Defaults to the whole working directory.",
- ),
- case_sensitive: z
- .boolean()
- .optional()
- .describe("Make the search case-sensitive. Default: false (case-insensitive)."),
- include_ext: z
- .string()
- .optional()
- .describe(
- 'Comma-separated list of file extensions to limit the search to (case-sensitive), e.g. "go,ts,lua".',
- ),
- exclude_pattern: z
- .string()
- .optional()
- .describe(
- 'Comma-separated list of path patterns to exclude (case-sensitive), e.g. "vendor,_test.go".',
- ),
- context: z
- .number()
- .int()
- .min(0)
- .optional()
- .describe(
- `Lines of context to show before and after each matching line (0-${MAX_CONTEXT}). When set, switches to a grep-style per-line window.`,
- ),
- result_limit: z
- .number()
- .int()
- .min(1)
- .optional()
- .describe(
- `Maximum number of file results to return. Default: ${DEFAULT_RESULT_LIMIT}, max: ${MAX_RESULT_LIMIT}.`,
- ),
- snippet_length: z
- .number()
- .int()
- .min(MIN_SNIPPET_LENGTH)
- .optional()
- .describe(
- `Snippet size in bytes for prose/text files (${MIN_SNIPPET_LENGTH}-${MAX_SNIPPET_LENGTH}). Has little effect on code files, which use a fixed line window — use 'context' to widen code snippets.`,
- ),
- only: z
- .enum(["code", "comments", "strings", "declarations", "usages"])
- .optional()
- .describe(
- "Restrict matches structurally: code, comments, strings, declarations (definitions like func/class/type), " +
- "or usages (call sites). Best-effort and language-dependent — strong for Go/TypeScript/Python/Lua/Luau, " +
- "unavailable for unsupported languages (which fall back to plain text ranking).",
- ),
- }),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const query = typeof args.query === "string" ? args.query : "";
- if (query.trim() === "") {
- return "Error: query is required (a non-empty string).";
- }
-
- // Resolve and contain the optional search path within the workdir.
- // Canonicalize so a symlink-in-workdir pointing outside is detected,
- // matching the containment semantics of list_files / read_file.
- const relPath = asString(args.path) ?? ".";
- const absoluteWorkDir = await canonicalize(workingDirectory);
- const searchDir = await canonicalize(workingDirectory, relPath);
- if (searchDir !== absoluteWorkDir && !searchDir.startsWith(`${absoluteWorkDir}/`)) {
- return `Error: Path "${relPath}" is outside the working directory.`;
- }
-
- // cs's --dir expects a directory; pointing it at a file silently
- // returns no matches. Catch that and give an actionable hint instead
- // of a misleading "No matches found".
- if (relPath !== ".") {
- try {
- const st = await stat(searchDir);
- if (!st.isDirectory()) {
- return `Error: Path "${relPath}" is a file, not a directory. The 'path' parameter scopes the search to a directory; use read_file to read a single file.`;
- }
- } catch {
- return `Error: Path "${relPath}" does not exist in the working directory.`;
- }
- }
-
- const flags = buildFlags(args, searchDir);
- // `--` terminates cs flag parsing so a query that begins with "-"
- // (e.g. "-hello" or "--foo") is treated as the positional search term
- // rather than parsed as a (possibly invalid) cs flag.
- const spawnArgs = [...flags, "--", query];
-
- let stdout = "";
- let stderr = "";
- const result = await new Promise<{
- code: number | null;
- signal: NodeJS.Signals | null;
- error?: string;
- errorCode?: string;
- }>((resolve) => {
- const child = spawn(resolveCsBin(), spawnArgs, {
- cwd: workingDirectory,
- env: process.env,
- timeout: TIMEOUT_MS,
- stdio: ["ignore", "pipe", "pipe"],
- });
- child.stdout?.on("data", (d: Buffer) => {
- stdout += d.toString();
- });
- child.stderr?.on("data", (d: Buffer) => {
- stderr += d.toString();
- });
- child.on("close", (code, signal) => resolve({ code, signal }));
- child.on("error", (err) =>
- resolve({
- code: null,
- signal: null,
- error: err.message,
- errorCode: (err as NodeJS.ErrnoException).code,
- }),
- );
- });
-
- if (result.error) {
- // The binary is missing or not executable — give an actionable hint.
- if (result.errorCode === "ENOENT" || result.error.includes("ENOENT")) {
- return missingBinaryError();
- }
- return `Error: failed to run cs: ${result.error}`;
- }
-
- // A signal kill (e.g. SIGTERM from the spawn timeout) or a non-zero
- // exit means cs failed — surface it (with stderr) instead of silently
- // reporting "No matches found". cs exits 0 even when there are no
- // matches, so a clean exit always falls through to the parsing below.
- if (result.signal) {
- const detail = stderr.trim() ? `\n${stderr.trim()}` : "";
- if (result.signal === "SIGTERM") {
- return `Error: cs search timed out after ${TIMEOUT_MS / 1000}s. Try a narrower query or a smaller path.${detail}`;
- }
- return `Error: cs was terminated by signal ${result.signal}.${detail}`;
- }
- if (result.code !== 0) {
- const detail = stderr.trim() ? `\n${stderr.trim()}` : "";
- return `Error: cs exited with code ${result.code}.${detail}`;
- }
-
- // cs prints `null` (and exit 0) when there are no matches.
- const trimmed = stdout.trim();
- if (trimmed === "" || trimmed === "null") {
- return "No matches found.";
- }
-
- let parsed: CsResult[];
- try {
- parsed = JSON.parse(trimmed) as CsResult[];
- } catch {
- // Couldn't parse — surface what cs produced so the caller isn't blind.
- const detail = stderr.trim() ? `\nstderr: ${stderr.trim()}` : "";
- return `Error: could not parse cs output as JSON.${detail}\n\nRaw output:\n${trimmed.slice(0, 2000)}`;
- }
-
- if (!Array.isArray(parsed) || parsed.length === 0) {
- return "No matches found.";
- }
-
- return formatResults(parsed, absoluteWorkDir);
- },
- };
-}
-
-/** Build the cs CLI flags (everything except the trailing query). */
-function buildFlags(args: Record<string, unknown>, searchDir: string): string[] {
- const flags: string[] = ["-f", "json", "--dir", searchDir];
-
- if (args.case_sensitive === true) flags.push("-c");
-
- const includeExt = asString(args.include_ext);
- if (includeExt) flags.push("-i", includeExt);
-
- const excludePattern = asString(args.exclude_pattern);
- if (excludePattern) flags.push("-x", excludePattern);
-
- // Snippet mode selection. cs's default ("auto") emits a `lines[]` array for
- // code but a single `content` string for prose (.md/.html/…), which our
- // renderer can't show — so prose results would come back as bare headers.
- // It also ignores -C/--context entirely in auto/lines mode.
- //
- // - No `context` given → force "lines": every file type (code AND prose)
- // returns a `lines[]` window, so prose snippets render too.
- // - `context` given → use "grep": the only mode where -C actually widens
- // the window; it likewise returns `lines[]` for all file types.
- if (typeof args.context === "number") {
- const context = clamp(Math.floor(args.context), 0, MAX_CONTEXT);
- flags.push("--snippet-mode", "grep", "-C", String(context));
- } else {
- flags.push("--snippet-mode", "lines");
- }
-
- const requestedLimit =
- typeof args.result_limit === "number"
- ? clamp(Math.floor(args.result_limit), 1, MAX_RESULT_LIMIT)
- : DEFAULT_RESULT_LIMIT;
- flags.push("--result-limit", String(requestedLimit));
-
- if (typeof args.snippet_length === "number") {
- const snippet = clamp(Math.floor(args.snippet_length), MIN_SNIPPET_LENGTH, MAX_SNIPPET_LENGTH);
- flags.push("-n", String(snippet));
- }
-
- const only = asString(args.only);
- if (only && ONLY_FLAGS[only]) flags.push(ONLY_FLAGS[only]);
-
- return flags;
-}
-
-/** Render cs JSON results into compact, readable per-file blocks. */
-function formatResults(results: CsResult[], absoluteWorkDir: string): string {
- const blocks: string[] = [];
- // Match the workdir only at a path boundary so a sibling dir that merely
- // shares the prefix (e.g. workdir "/app" vs "/app-secrets") isn't treated
- // as nested and rendered as a "../app-secrets/..." relative path.
- const workdirPrefix = absoluteWorkDir.endsWith(sep) ? absoluteWorkDir : absoluteWorkDir + sep;
- for (const r of results) {
- // Present paths relative to the workdir so output is portable and compact.
- const insideWorkdir = r.location === absoluteWorkDir || r.location.startsWith(workdirPrefix);
- const rel = insideWorkdir ? relative(absoluteWorkDir, r.location) || r.filename : r.location;
- const lang = r.language ? ` [${r.language}]` : "";
- const score = typeof r.score === "number" ? ` (score ${r.score.toFixed(2)})` : "";
- const header = `${rel}${lang}${score}`;
-
- let body: string[];
- if (r.lines && r.lines.length > 0) {
- body = r.lines.map((l) => {
- const marker = l.match_positions && l.match_positions.length > 0 ? ">" : " ";
- return ` ${marker} ${l.line_number}: ${truncateLine(l.content)}`;
- });
- } else if (r.content && r.content.trim() !== "") {
- // Fallback for cs's "snippet"-mode shape (no per-line numbers): show
- // the snippet text itself so the result isn't a bare header.
- body = r.content.split("\n").map((line) => ` ${truncateLine(line)}`);
- } else {
- body = [" (match in file; no snippet available)"];
- }
-
- blocks.push([header, ...body].join("\n"));
- }
-
- const count = results.length;
- const heading = `Found matches in ${count} file${count === 1 ? "" : "s"} (ranked by relevance):`;
- return [heading, "", blocks.join("\n\n")].join("\n");
-}
-
-function clamp(n: number, min: number, max: number): number {
- return Math.min(max, Math.max(min, n));
-}
-
-/** Cap an individual snippet line so a minified/generated line can't bloat output. */
-function truncateLine(line: string): string {
- if (line.length <= MAX_LINE_CHARS) return line;
- return `${line.slice(0, MAX_LINE_CHARS)}… [line truncated, ${line.length.toLocaleString()} chars]`;
-}
-
-/**
- * Coerce a tool argument to a trimmed string, or undefined. Guards against a
- * model hallucinating a non-string (e.g. an array `["ts","go"]`) for a
- * string-typed param: returning undefined makes the flag a no-op instead of
- * throwing `x.trim is not a function` and crashing the tool call.
- */
-function asString(v: unknown): string | undefined {
- if (typeof v !== "string") return undefined;
- const t = v.trim();
- return t === "" ? undefined : t;
-}
-
-function missingBinaryError(): string {
- return [
- "Error: search_code requires the 'cs' (code spelunker) binary, which was not found.",
- "Install it with: go install github.com/boyter/cs/[email protected]",
- "or set the DISPATCH_CS_BIN environment variable to the path of a cs binary.",
- "(In the official Docker images cs is bundled at /usr/local/bin/cs.)",
- ].join("\n");
-}
diff --git a/packages/core/src/tools/send-to-tab.ts b/packages/core/src/tools/send-to-tab.ts
deleted file mode 100644
index eae6bfa..0000000
--- a/packages/core/src/tools/send-to-tab.ts
+++ /dev/null
@@ -1,198 +0,0 @@
-import { z } from "zod";
-import type { ToolDefinition } from "../types/index.js";
-
-/**
- * A tab reference surfaced to the `send_to_tab` / `read_tab` tools. The tools
- * are intentionally decoupled from the DB `TabRow` shape — the AgentManager
- * maps `resolveTabPrefix(...)` results down to this minimal projection so the
- * tools (and their unit tests) never depend on the persistence layer.
- */
-export interface ResolvedTabRef {
- /** The tab's canonical full UUID. */
- id: string;
- /** The tab's display title (for disambiguation hints). */
- title: string;
- /** The tab's current short handle (shortest unique prefix). */
- handle: string;
-}
-
-/**
- * Outcome of resolving a short tab handle. Mirrors core's
- * `ResolveTabPrefixResult` but over the minimal `ResolvedTabRef` projection.
- */
-export type TabResolution =
- | { status: "ok"; tab: ResolvedTabRef }
- | { status: "none" }
- | { status: "ambiguous"; matches: ResolvedTabRef[] };
-
-export interface SendToTabCallbacks {
- /** Resolve a (possibly short) handle to one open tab. */
- resolveShortId(prefix: string): TabResolution;
- /**
- * Deliver `message` to `tabId`. If the target is mid-turn the message is
- * queued (same path as a user message); if idle/errored it wakes the tab
- * and starts a new turn. Returns quickly — does NOT block on the turn.
- */
- deliver(
- tabId: string,
- message: string,
- ):
- | Promise<{ status: "queued" | "started" | "suppressed" }>
- | { status: "queued" | "started" | "suppressed" };
- /** Snapshot of currently-open tabs, for "available tabs" error hints. */
- listOpenHandles(): Array<{ handle: string; title: string }>;
- /** The calling tab's own id + handle — used to block self-sends and to
- * stamp provenance onto the delivered message. */
- self: { id: string; handle: string };
- /**
- * Whether THIS calling tab also has the `read_tab` tool granted. The
- * tab-messaging permissions are split, so a tab can hold `send_to_tab`
- * without `read_tab`. When false, the tool must NOT tell the agent to use
- * `read_tab` (it doesn't have it) — replies only arrive on their own.
- */
- canReadTab: boolean;
-}
-
-/** Render the "available tabs" hint shared by the none/ambiguous branches. */
-function renderOpenHandles(handles: Array<{ handle: string; title: string }>): string {
- if (handles.length === 0) return "No other tabs are currently open.";
- const lines = handles.map((h) => ` - ${h.handle}: ${h.title}`);
- return ["Currently open tabs:", ...lines].join("\n");
-}
-
-export function createSendToTabTool(callbacks: SendToTabCallbacks): ToolDefinition {
- // The `read_tab` follow-up hint is only truthful when this tab actually
- // holds the `read_tab` tool (the permissions are split). When it doesn't,
- // the only honest guidance is that a reply will wake it as a new message — never tell
- // the agent to call a tool it wasn't granted.
- const waitLine = callbacks.canReadTab
- ? "money. If the target replies it will WAKE you with a new message in a later turn; you"
- : "money. If the target replies it will WAKE you with a new message in a later turn.";
- const readTabLine = callbacks.canReadTab
- ? ["can also call 'read_tab' with the same ID in a FUTURE turn to check. If you have other"]
- : [];
- const keepGoingLine = callbacks.canReadTab
- ? "work to do, keep going; if you are ONLY waiting for the reply, end your turn now."
- : "If you have other work to do, keep going; if you are ONLY waiting for the reply, end your turn now.";
- return {
- name: "send_to_tab",
- description: [
- "Send a message to another tab (agent) by its short ID — the handle shown in the tab bar.",
- "",
- "Behaviour mirrors a user sending a message:",
- " - If the target tab is mid-turn (busy), your message is QUEUED and picked up next.",
- " - If the target tab is idle, your message WAKES it and starts a new turn.",
- "",
- "This is fire-and-forget: it returns immediately and does NOT wait for a reply.",
- "Do NOT sleep, poll, or run shell commands to wait for a reply — that wastes turns and",
- waitLine,
- ...readTabLine,
- keepGoingLine,
- "",
- "Your tab ID is auto-added to the top of the message so the recipient knows who to reply",
- "to. The recipient must use this same 'send_to_tab' tool (addressed to your ID) to answer;",
- "a plain text response reaches only their own user, not you.",
- "IDs are git-style prefixes: pass any length that uniquely identifies the target (min 4 chars).",
- "If the ID is ambiguous you'll be asked to add a character.",
- ].join("\n"),
- parameters: z.object({
- tab_id: z
- .string()
- .describe(
- "The short ID (handle) of the target tab, as shown in the tab bar. Any unique-length prefix of the tab's id works (min 4 chars).",
- ),
- message: z
- .string()
- .describe("The message to deliver to the target tab, exactly as a user would type it."),
- }),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const rawId = (args.tab_id as string | undefined)?.trim() ?? "";
- const message = (args.message as string | undefined) ?? "";
-
- if (!rawId) {
- return `Error: tab_id is required.\n\n${renderOpenHandles(callbacks.listOpenHandles())}`;
- }
- if (!message.trim()) {
- return "Error: message must not be empty.";
- }
-
- const resolution = callbacks.resolveShortId(rawId);
-
- if (resolution.status === "none") {
- return [
- `Error: no open tab matches the ID "${rawId}".`,
- "",
- renderOpenHandles(callbacks.listOpenHandles()),
- ].join("\n");
- }
- if (resolution.status === "ambiguous") {
- const matches = resolution.matches.map((m) => ` - ${m.handle}: ${m.title}`).join("\n");
- return [
- `Error: the ID "${rawId}" is ambiguous — it matches multiple open tabs:`,
- matches,
- "",
- "Add one or more characters to disambiguate.",
- ].join("\n");
- }
-
- const target = resolution.tab;
-
- if (target.id === callbacks.self.id) {
- return "Error: cannot send a message to your own tab.";
- }
-
- // Stamp provenance so the recipient (and the watching user) can see
- // which tab the message came from and how to reply. The header makes
- // clear this is a PEER AGENT, not the recipient's own user, and the
- // footer states the reply contract: a reply (only if warranted) must
- // go back through `send_to_tab`, since a plain text answer reaches
- // only the recipient's own user — not this sender.
- const delivered = [
- `[message from tab ${callbacks.self.handle} — this is another agent, NOT your user]`,
- "",
- message,
- "",
- `[To reply to tab ${callbacks.self.handle}, use the send_to_tab tool with tab_id "${callbacks.self.handle}". ONLY reply if this message asks you to, or your user tells you to — it may just be context or instructions. A plain text response goes to your own user, not to this agent.]`,
- ].join("\n");
-
- try {
- const result = await callbacks.deliver(target.id, delivered);
- if (result.status === "suppressed") {
- // The target hit its automatic agent-to-agent wake limit. The
- // message was preserved (queued) but did NOT start a turn — a
- // human must step in. Tell the sender plainly so it stops
- // hammering the target and creating a runaway loop.
- return [
- `Message HELD for tab ${target.handle} (${target.title}) — it was NOT delivered as a wake.`,
- `That tab has reached its automatic agent-to-agent message limit, so it will not`,
- `auto-respond again until a human sends it a message. Do not keep resending:`,
- `your message is already queued and will be seen when a human resumes that tab.`,
- ].join("\n");
- }
- const verb =
- result.status === "queued"
- ? "queued (target is busy; it will be picked up next turn)"
- : "delivered (target was idle; a new turn has started)";
- const tail = callbacks.canReadTab
- ? [
- "Do NOT sleep, poll, or run commands to wait for a reply. If the target replies it",
- `will WAKE you with a new message later; you can also call read_tab with "${target.handle}"`,
- "in a FUTURE turn to check. Keep working if you have other tasks; if you are ONLY",
- "waiting for this reply, end your turn now.",
- ]
- : [
- "Do NOT sleep, poll, or run commands to wait for a reply. If the target replies it",
- "will WAKE you with a new message later. Keep working if you have other tasks; if",
- "you are ONLY waiting for this reply, end your turn now.",
- ];
- return [
- `Message ${verb}. Target tab: ${target.handle} (${target.title}).`,
- "",
- ...tail,
- ].join("\n");
- } catch (err) {
- return `Error delivering message: ${err instanceof Error ? err.message : String(err)}`;
- }
- },
- };
-}
diff --git a/packages/core/src/tools/shell-analyze.ts b/packages/core/src/tools/shell-analyze.ts
deleted file mode 100644
index b70108b..0000000
--- a/packages/core/src/tools/shell-analyze.ts
+++ /dev/null
@@ -1,166 +0,0 @@
-import { readFile } from "node:fs/promises";
-import { createRequire } from "node:module";
-import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
-import * as BashArity from "./bash-arity.js";
-
-// Commands that touch files — triggers external_directory check.
-// Includes any command that takes file paths as arguments and could leak
-// information about external directories.
-//
-// Known gaps (not currently checked):
-// - Redirections: `echo x > /etc/file` — the redirect target is not inspected
-// - `cd` state changes: we don't track cwd mutations across pipeline stages
-// - Interpreter escapes: `python -c "open('/etc/passwd')"`, `node -e "..."` bypass this entirely
-const FILE_COMMANDS = new Set([
- "rm",
- "cp",
- "mv",
- "mkdir",
- "touch",
- "chmod",
- "chown",
- "cat",
- "ls",
- "find",
- "grep",
- "head",
- "tail",
- "less",
- "more",
- "wc",
- "diff",
- "file",
- "stat",
- "du",
- "df",
-]);
-
-// Lazy-initialized parser
-let parserPromise: Promise<Parsers> | null = null;
-
-interface Parsers {
- bash: import("web-tree-sitter").Parser;
-}
-
-async function getParser(): Promise<Parsers> {
- if (parserPromise) return parserPromise;
- parserPromise = initParser();
- return parserPromise;
-}
-
-async function initParser(): Promise<Parsers> {
- const { Parser, Language } = await import("web-tree-sitter");
-
- // Load the main WASM binary from node_modules
- const require = createRequire(import.meta.url);
- const webTreeSitterPath = require.resolve("web-tree-sitter/web-tree-sitter.wasm");
- const wasmBinary = await readFile(webTreeSitterPath);
- await Parser.init({ wasmBinary });
-
- const bashWasmPath = require.resolve("tree-sitter-bash/tree-sitter-bash.wasm");
- const bashLang = await Language.load(bashWasmPath);
-
- const bash = new Parser();
- bash.setLanguage(bashLang);
-
- return { bash };
-}
-
-// Analyze a shell command and return permission patterns
-export async function analyzeCommand(
- command: string,
- workingDirectory: string,
-): Promise<{ dirs: string[]; patterns: string[]; always: string[] }> {
- try {
- const parsers = await getParser();
- const tree = parsers.bash.parse(command);
- if (!tree) return { dirs: [], patterns: [command], always: [] };
-
- return collect(tree.rootNode, command, workingDirectory);
- } catch {
- // Parse failure — return basic patterns
- return { dirs: [], patterns: [command], always: [] };
- }
-}
-
-function collect(
- node: import("web-tree-sitter").Node,
- _source: string,
- wd: string,
-): { dirs: string[]; patterns: string[]; always: string[] } {
- const dirs: string[] = [];
- const patterns: string[] = [];
- const always: string[] = [];
-
- // Walk all command nodes
- const commands = node.descendantsOfType("command");
-
- for (const cmd of commands) {
- const parts = extractParts(cmd);
- const name = parts[0]?.toLowerCase();
- if (!name) continue;
-
- // Get the command source text
- const cmdText = cmd.text;
- patterns.push(cmdText);
-
- // Normalize to always pattern
- always.push(`${BashArity.prefix(parts).join(" ")} *`);
-
- // Check if this is a file-touching command
- if (FILE_COMMANDS.has(name)) {
- // Extract path arguments (skip flags starting with -)
- const pathArgs = parts.slice(1).filter((a) => !a.startsWith("-"));
- for (const arg of pathArgs) {
- const resolved = resolvePath(arg, wd);
- if (resolved && !isInsideWorkspace(resolved, wd)) {
- const parent = dirname(resolved);
- dirs.push(parent);
- }
- }
- }
- }
-
- return {
- dirs: [...new Set(dirs)],
- patterns: [...new Set(patterns)],
- always: [...new Set(always)],
- };
-}
-
-// Helper to extract command parts from a command AST node
-function extractParts(cmd: import("web-tree-sitter").Node): string[] {
- const parts: string[] = [];
- for (const child of cmd.children) {
- if (
- child.type === "command_name" ||
- child.type === "word" ||
- child.type === "string" ||
- child.type === "raw_string"
- ) {
- const text = child.text.replace(/^['"]|['"]$/g, "");
- if (text) parts.push(text);
- }
- }
- return parts;
-}
-
-function resolvePath(arg: string, wd: string): string | null {
- try {
- if (isAbsolute(arg)) return arg;
- return resolve(wd, arg);
- } catch {
- return null;
- }
-}
-
-function isInsideWorkspace(filePath: string, wd: string): boolean {
- const normalizedWd = resolve(wd);
- const rel = relative(normalizedWd, filePath);
- // rel === "" means filePath IS the workspace root — that is inside.
- // If relative path starts with "../" or is ".." exactly, or is an absolute path
- // (on Windows when drives differ), the file is outside the workspace.
- const isOutside =
- rel.startsWith(`..${sep}`) || rel.startsWith("../") || rel === ".." || isAbsolute(rel);
- return !isOutside;
-}
diff --git a/packages/core/src/tools/summon.ts b/packages/core/src/tools/summon.ts
deleted file mode 100644
index 2a076e6..0000000
--- a/packages/core/src/tools/summon.ts
+++ /dev/null
@@ -1,447 +0,0 @@
-import { z } from "zod";
-import type { AgentDefinition, ToolDefinition } from "../types/index.js";
-
-export interface SummonCallbacks {
- spawn(options: {
- task: string;
- tools: string[];
- workingDirectory?: string;
- /**
- * Optional slug of an `AgentDefinition` (loaded from
- * `~/.config/dispatch/agents/` or `<projectDir>/.dispatch/agents/`)
- * to use as the basis for the spawned child. When provided,
- * the definition's tools, models, and cwd override the
- * `tools` and `workingDirectory` parameters passed alongside.
- */
- agentSlug?: string;
- /**
- * When true, spawn the agent as an independent top-level "user
- * agent" tab (no parent, persistent, fire-and-forget) instead of
- * a subagent child tab. Only honoured when the spawning agent has
- * the `perm_user_agent` permission.
- */
- topLevel?: boolean;
- }): Promise<string>;
- getResult(
- agentId: string,
- ): Promise<{ status: "done"; result: string } | { status: "error"; error: string }>;
-}
-
-/**
- * Summary of an agent definition surfaced to the calling LLM in the
- * summon tool's description. The shape is intentionally minimal — full
- * TOML inspection is done by reading the definition file directly,
- * which all agents are allowed to do by default.
- */
-export interface AvailableAgent {
- slug: string;
- name: string;
- description: string;
- /** Filesystem path of the TOML the agent can read for full details. */
- path: string;
-}
-
-/**
- * Render a labelled list of agents. Returns an empty array when there
- * are no agents so callers can omit the group entirely.
- */
-function renderAgentGroup(label: string, agents: AvailableAgent[]): string[] {
- if (agents.length === 0) return [];
- const lines: string[] = [label];
- for (const a of agents) {
- const desc = a.description ? ` — ${a.description}` : "";
- lines.push(` - ${a.slug}: ${a.name}${desc}`);
- }
- return lines;
-}
-
-/**
- * Build the prose paragraph that lists available agent definitions plus
- * the disk locations where they live, injected into the summon tool's
- * description.
- *
- * `subagentEnabled` and `userAgentEnabled` independently control which
- * groups are shown — they mirror the `perm_summon` and `perm_user_agent`
- * permissions respectively:
- * - subagents only → generic "Available agents" heading;
- * - user agents only → a single user-agent group (top_level is implied);
- * - both → two labelled groups so the LLM understands which slugs
- * require `top_level=true`.
- *
- * Returns a compact "no agents defined" notice when nothing is visible.
- */
-function buildAgentsCatalog(
- subagents: AvailableAgent[],
- userAgents: AvailableAgent[],
- agentDirs: string[],
- userAgentEnabled: boolean,
- subagentEnabled: boolean,
-): string {
- const lines: string[] = [];
- lines.push("");
- lines.push("Agent definitions live on disk and can be inspected with read_file/list_files:");
- for (const d of agentDirs) {
- lines.push(` - ${d}`);
- }
-
- const visibleSubagents = subagentEnabled ? subagents : [];
- const visibleUserAgents = userAgentEnabled ? userAgents : [];
- if (visibleSubagents.length === 0 && visibleUserAgents.length === 0) {
- lines.push("");
- lines.push("No agent definitions are currently defined.");
- return lines.join("\n");
- }
-
- lines.push("");
- lines.push("To summon a specific agent, pass its slug as the 'agent' parameter.");
- lines.push("When 'agent' is set, the child inherits that definition's tools, models,");
- lines.push("and working directory; the 'tools' parameter is ignored.");
- lines.push("");
-
- // User-agent-only mode: list just the user agents. top_level is implied
- // (it is the only thing this grant can spawn), so the heading omits it.
- if (!subagentEnabled && userAgentEnabled) {
- lines.push(
- ...renderAgentGroup(
- "User agents (spawned as independent top-level tabs):",
- visibleUserAgents,
- ),
- );
- return lines.join("\n");
- }
-
- // Subagent-only mode: single generic heading.
- if (!userAgentEnabled) {
- lines.push(...renderAgentGroup("Available agents:", visibleSubagents));
- return lines.join("\n");
- }
-
- // Both enabled: two labelled groups.
- const subagentLines = renderAgentGroup("Subagents (spawned as child tabs):", visibleSubagents);
- const userAgentLines = renderAgentGroup(
- "User agents (spawned as independent top-level tabs, requires top_level=true):",
- visibleUserAgents,
- );
- if (subagentLines.length > 0) {
- lines.push(...subagentLines);
- }
- if (userAgentLines.length > 0) {
- if (subagentLines.length > 0) lines.push("");
- lines.push(...userAgentLines);
- }
- return lines.join("\n");
-}
-
-/**
- * Factory for the `summon` tool. Accepts a snapshot of agent definitions
- * available at the time the tool is registered so the LLM's view of
- * which agents exist matches what `spawnChildAgent` can actually load.
- *
- * `agentDirs` is the list of filesystem paths the catalog references in
- * its description; this is information-only — the runtime resolves
- * slugs through `loadAgent` independently.
- *
- * `userAgentEnabled` mirrors the `perm_user_agent` permission and
- * `subagentEnabled` mirrors the `perm_summon` permission. They are
- * independent: the tool is registered whenever at least one is granted.
- * - subagentEnabled only → spawn ordinary subagents (no `top_level`);
- * - userAgentEnabled only → spawn ONLY top-level user agents
- * (`top_level` is forced on, the `background` knob is dropped, and
- * the catalog lists user agents only);
- * - both → full behavior (subagents plus `top_level` user agents).
- */
-export function createSummonTool(
- _defaultWorkingDirectory: string,
- callbacks: SummonCallbacks,
- availableSubagents: AvailableAgent[] = [],
- availableUserAgents: AvailableAgent[] = [],
- agentDirs: string[] = [],
- userAgentEnabled = false,
- subagentEnabled = true,
-): ToolDefinition {
- // When only the user-agent permission is granted the tool spawns user
- // agents exclusively: `top_level` is implied (and forced), subagent
- // mechanics (background, retrieve, parallel work) are irrelevant.
- const userAgentOnly = userAgentEnabled && !subagentEnabled;
-
- const catalog = buildAgentsCatalog(
- availableSubagents,
- availableUserAgents,
- agentDirs,
- userAgentEnabled,
- subagentEnabled,
- );
- const subagentSlugs = availableSubagents.map((a) => a.slug);
- const userAgentSlugs = availableUserAgents.map((a) => a.slug);
- const allSlugs = userAgentOnly
- ? userAgentSlugs
- : userAgentEnabled
- ? [...subagentSlugs, ...userAgentSlugs]
- : subagentSlugs;
-
- const toolNamesList = [
- "The 'tools' parameter controls what the child can do. Available tool names:",
- " - read_file: Read file contents",
- " - read_file_slice: Read a character-range slice of a single line",
- " - list_files: List files and directories",
- " - write_file: Write/edit files",
- " - run_shell: Execute shell commands",
- " - search_code: Search the codebase with the cs ranked code-search engine",
- " - todo: Track work items",
- " - summon: Spawn its own child agents (enables nesting)",
- " - retrieve: Collect results from its children (required if summon is given)",
- " - web_search: Search the web",
- " - youtube_transcribe: Fetch YouTube video transcripts",
- " - send_to_tab: Send a message to another tab/agent by its ID",
- " - read_tab: Read another tab/agent's latest response by its ID",
- ];
-
- const description = userAgentOnly
- ? [
- "Spawn an independent top-level user agent to work on a task.",
- "",
- "User agents are first-class top-level tabs with no parent. They are",
- "fire-and-forget: you get an agent_id back but cannot retrieve their result.",
- "The user agent runs in its own tab visible to the user.",
- "",
- ...toolNamesList,
- "",
- "The 'agent' parameter is required — every spawned agent must use a definition.",
- "Tools default to the agent definition's tools, intersected with your own tools (you can't grant capabilities you don't have).",
- catalog,
- ].join("\n")
- : [
- "Spawn a new child agent to work on a task independently.",
- "",
- "By default, blocks until the child agent finishes and returns the result directly.",
- "Set background=true to return immediately with an agent_id instead — use retrieve to collect the result later.",
- "",
- "The child agent runs in its own tab visible to the user. Use the 'retrieve' tool with the returned agent_id to get the result when needed.",
- "",
- "Pattern for parallel work:",
- " 1. Call summon multiple times with background=true to start several agents",
- " 2. Do your own work or wait",
- " 3. Call retrieve for each agent_id to collect results",
- ...(userAgentEnabled
- ? [
- "",
- "Set top_level=true to spawn an independent user agent — a first-class",
- "top-level tab with no parent. User agents are fire-and-forget: you get",
- "an agent_id back but cannot retrieve their result. top_level requires an",
- "'agent' definition listed under 'User agents' below.",
- ]
- : []),
- "",
- ...toolNamesList,
- "",
- "The 'agent' parameter is required — every spawned agent must use a definition.",
- "Tools default to the agent definition's tools, intersected with your own tools (you can't grant capabilities you don't have).",
- catalog,
- ].join("\n");
-
- const parametersShape = {
- task: z
- .string()
- .describe(
- "Detailed instructions for the child agent. Be specific about what it should do and what it should return.",
- ),
- agent: z
- .string()
- .describe(
- [
- "Slug of an agent definition to use as the basis for the child agent.",
- "Required. The child inherits the definition's tools, models, and",
- "working directory; the 'tools' parameter only narrows them further.",
- "Inspect the agent directories listed above to discover which slugs",
- "are available and what each one does.",
- allSlugs.length > 0 ? `Available slugs: ${allSlugs.join(", ")}.` : "",
- ]
- .filter(Boolean)
- .join(" "),
- ),
- // `top_level` is only an explicit choice when BOTH subagents and user
- // agents are available. In user-agent-only mode it is implied (forced
- // on), so the knob is omitted entirely.
- ...(userAgentEnabled && !userAgentOnly
- ? {
- top_level: z
- .boolean()
- .optional()
- .describe(
- [
- "If true, spawn the agent as an independent top-level user agent tab",
- "instead of a child subagent. User agents have no parent, persist on",
- "their own, and are fire-and-forget (cannot be retrieved). Requires an",
- "'agent' definition listed under 'User agents'. The 'background' option",
- "is ignored when top_level is true.",
- ].join(" "),
- ),
- }
- : {}),
- tools: z
- .array(
- z.enum([
- "read_file",
- "read_file_slice",
- "list_files",
- "write_file",
- "run_shell",
- "search_code",
- "key_usage",
- "todo",
- "summon",
- "retrieve",
- "web_search",
- "youtube_transcribe",
- "send_to_tab",
- "read_tab",
- ]),
- )
- .optional()
- .describe(
- "Tool names to give the child. Defaults to the agent definition's tools. Intersected with the spawning agent's tools (you can't grant capabilities you don't have).",
- ),
- working_directory: z
- .string()
- .optional()
- .describe(
- "Absolute path for the child to work in. Defaults to the agent definition's cwd (or the spawning agent's directory).",
- ),
- // `background` is meaningless for fire-and-forget user agents, so the
- // knob is omitted in user-agent-only mode.
- ...(userAgentOnly
- ? {}
- : {
- background: z
- .boolean()
- .optional()
- .describe(
- "If true, returns immediately with an agent_id for later retrieval. If false (default), blocks until the child agent finishes and returns the result directly. Ignored when top_level is true.",
- ),
- }),
- };
-
- return {
- name: "summon",
- description,
- parameters: z.object(parametersShape),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const task = args.task as string;
- const agentSlug = args.agent as string | undefined;
- const tools = args.tools as string[] | undefined;
- const workingDirectory = args.working_directory as string | undefined;
- const background = (args.background as boolean | undefined) ?? false;
- // User-agent-only mode always spawns top-level user agents. When both
- // capabilities are present the caller chooses via `top_level`. When
- // only subagents are available, top-level spawning is unavailable.
- const topLevel = userAgentOnly
- ? true
- : userAgentEnabled
- ? ((args.top_level as boolean | undefined) ?? false)
- : false;
-
- try {
- const agentId = await callbacks.spawn({
- task,
- tools: tools ?? [],
- ...(workingDirectory ? { workingDirectory } : {}),
- ...(agentSlug ? { agentSlug } : {}),
- ...(topLevel ? { topLevel: true } : {}),
- });
-
- if (topLevel) {
- // User agents are always fire-and-forget — never block on a
- // result and make it explicit that retrieve won't work.
- return [
- `User agent spawned successfully.`,
- `agent_id: ${agentId}`,
- ``,
- `The user agent is now working independently in its own top-level tab.`,
- `It is fire-and-forget — you cannot retrieve its result.`,
- ].join("\n");
- }
-
- if (!background) {
- // Block until the child agent completes. Always prefix the
- // result with `agent_id: <uuid>` so the frontend's
- // ToolCallDisplay regex (`agent_id:\s*([a-f0-9-]+)`) can
- // surface the "Open Tab" button for foreground summons too —
- // not just background ones. The child's tab still exists and
- // holds the full conversation, so the user should always be
- // able to open it.
- const result = await callbacks.getResult(agentId);
- if (result.status === "done") {
- return `agent_id: ${agentId}\n\n${result.result}`;
- }
- return `agent_id: ${agentId}\n\nError from child agent: ${result.error}`;
- }
-
- return [
- `Agent spawned successfully.`,
- `agent_id: ${agentId}`,
- ``,
- `The child agent is now working on the task in its own tab.`,
- `Use the retrieve tool with this agent_id to get the result when ready.`,
- ].join("\n");
- } catch (err) {
- return `Error spawning agent: ${err instanceof Error ? err.message : String(err)}`;
- }
- },
- };
-}
-
-/**
- * Build the `AvailableAgent[]` projection of an `AgentDefinition` list,
- * deriving each entry's readable `path` from its scope+slug.
- */
-function toAvailableAgents(
- defs: AgentDefinition[],
- globalDir: string,
- projectDir: string | null,
-): AvailableAgent[] {
- return defs.map((d) => {
- const baseDir =
- d.scope === "global"
- ? globalDir
- : projectDir
- ? `${projectDir.replace(/\/$/, "")}/.dispatch/agents`
- : globalDir;
- return {
- slug: d.slug,
- name: d.name,
- description: d.description,
- path: `${baseDir}/${d.slug}.toml`,
- };
- });
-}
-
-/**
- * Subagent definitions (`is_subagent === true`) — spawned as child tabs.
- */
-export function toAvailableSubagents(
- defs: AgentDefinition[],
- globalDir: string,
- projectDir: string | null,
-): AvailableAgent[] {
- return toAvailableAgents(
- defs.filter((d) => d.is_subagent),
- globalDir,
- projectDir,
- );
-}
-
-/**
- * User-agent definitions (`is_subagent !== true`) — spawnable as
- * independent top-level tabs when `perm_user_agent` is granted.
- */
-export function toAvailableUserAgents(
- defs: AgentDefinition[],
- globalDir: string,
- projectDir: string | null,
-): AvailableAgent[] {
- return toAvailableAgents(
- defs.filter((d) => d.is_subagent !== true),
- globalDir,
- projectDir,
- );
-}
diff --git a/packages/core/src/tools/task-list.ts b/packages/core/src/tools/task-list.ts
deleted file mode 100644
index 98dcf01..0000000
--- a/packages/core/src/tools/task-list.ts
+++ /dev/null
@@ -1,149 +0,0 @@
-import { z } from "zod";
-import type { TaskItem, TaskStatus, ToolDefinition } from "../types/index.js";
-
-/**
- * Valid task statuses. Matches opencode's todo lifecycle:
- * - pending not started
- * - in_progress actively working (exactly ONE at a time)
- * - completed finished successfully
- * - cancelled no longer needed
- */
-const VALID_STATUSES: ReadonlySet<TaskStatus> = new Set<TaskStatus>([
- "pending",
- "in_progress",
- "completed",
- "cancelled",
-]);
-
-function normalizeStatus(value: unknown): TaskStatus {
- return typeof value === "string" && VALID_STATUSES.has(value as TaskStatus)
- ? (value as TaskStatus)
- : "pending";
-}
-
-/**
- * Declarative, whole-list task store (ported from opencode's `todowrite`).
- *
- * The model never sees ids and never issues per-item mutations. Instead it
- * sends the ENTIRE desired list on every call and {@link setTasks} rebuilds the
- * stored list, assigning fresh positional ids. This is idempotent and
- * eliminates the id-bookkeeping / "task not found" / delta-reasoning failure
- * modes of the old imperative CRUD interface.
- */
-export class TaskList {
- private tasks: TaskItem[] = [];
- private listeners: Array<(tasks: TaskItem[]) => void> = [];
-
- private notify(): void {
- const snapshot = this.getTasks();
- for (const listener of this.listeners) {
- listener(snapshot);
- }
- }
-
- getTasks(): TaskItem[] {
- return this.tasks.map((t) => ({ ...t }));
- }
-
- /**
- * Replace the entire list. Each item is assigned a fresh positional id
- * (`task-1`, `task-2`, …). Invalid/missing statuses fall back to
- * `pending`; an empty array clears the list. Always notifies listeners.
- */
- setTasks(items: Array<{ content: string; status?: unknown }>): TaskItem[] {
- this.tasks = items.map((item, index) => ({
- id: `task-${index + 1}`,
- content: item.content,
- status: normalizeStatus(item.status),
- }));
- this.notify();
- return this.getTasks();
- }
-
- onChange(callback: (tasks: TaskItem[]) => void): () => void {
- this.listeners.push(callback);
- return () => {
- this.listeners = this.listeners.filter((l) => l !== callback);
- };
- }
-}
-
-/**
- * Rich tool description adapted from opencode's `todowrite.txt`. Teaches the
- * declarative whole-list cadence and the status lifecycle.
- */
-export const TODO_DESCRIPTION = `Create and maintain a structured todo list for the current session to track progress and surface your plan to the user.
-
-This is a DECLARATIVE, whole-list tool. There are no ids and no per-item actions: every call sends the ENTIRE list in the \`todos\` parameter and REPLACES the previous list. To change one item, resend the whole list with that item changed. To clear the list, send an empty array.
-
-## When to use
-- The task requires 3+ distinct steps and benefits from planning
-- The user provides multiple tasks (numbered or comma-separated) or asks for a todo list
-- New instructions arrive — capture them as todos
-- You start a task — mark it in_progress (only one at a time) before working
-- You finish a task — mark it completed and add any follow-ups discovered
-
-## When NOT to use
-- A single, straightforward task (or fewer than 3 trivial steps)
-- Purely informational or conversational requests
-- When tracking adds no organizational value
-
-## States
-- pending — not started
-- in_progress — actively working (exactly ONE at a time)
-- completed — finished successfully
-- cancelled — no longer needed
-
-## Rules
-- Send the full desired list every time; the tool replaces the stored list
-- Update status in real time; do not batch completions
-- Mark completed only after the work is actually done (including any required verification), never on intent
-- Keep exactly one in_progress while work remains
-- If blocked or partial, keep it in_progress and add a follow-up todo describing the blocker
-- Items should be specific and actionable; break large work into smaller steps`;
-
-export function createTaskListTool(taskList: TaskList): ToolDefinition {
- return {
- name: "todo",
- description: TODO_DESCRIPTION,
- parameters: z.object({
- todos: z
- .array(
- z.object({
- content: z.string().describe("Brief, actionable description of the task"),
- status: z
- .enum(["pending", "in_progress", "completed", "cancelled"])
- .describe("Current status of the task"),
- }),
- )
- .describe("The complete, updated todo list. Replaces the previous list entirely."),
- }),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const rawTodos = args.todos;
- if (!Array.isArray(rawTodos)) {
- return "Error: 'todos' must be an array of { content, status } items (send the whole list).";
- }
-
- const items: Array<{ content: string; status?: unknown }> = [];
- for (const entry of rawTodos) {
- if (!entry || typeof entry !== "object") {
- return "Error: each todo must be an object with a 'content' string and a 'status'.";
- }
- const content = (entry as Record<string, unknown>).content;
- if (typeof content !== "string" || content.trim() === "") {
- return "Error: each todo requires a non-empty 'content' string.";
- }
- items.push({
- content,
- status: (entry as Record<string, unknown>).status,
- });
- }
-
- const stored = taskList.setTasks(items);
- // Echo the canonical stored list back WITHOUT ids — the model must
- // never start tracking ids; it always resends the whole list.
- const echo = stored.map((t) => ({ content: t.content, status: t.status }));
- return JSON.stringify(echo);
- },
- };
-}
diff --git a/packages/core/src/tools/truncate.ts b/packages/core/src/tools/truncate.ts
deleted file mode 100644
index 8c62174..0000000
--- a/packages/core/src/tools/truncate.ts
+++ /dev/null
@@ -1,142 +0,0 @@
-import { mkdirSync, rmSync, writeFileSync } from "node:fs";
-import { dirname, join } from "node:path";
-
-// ─── Constants ───────────────────────────────────────────────────
-//
-// A tool result that exceeds *either* MAX_CHARS or MAX_LINES is treated
-// as oversized: the full content is written to a spill file under
-// /tmp/dispatch/tool-results/<tabId>/<callId>.txt and the model receives
-// HEAD_CHARS from the start + TAIL_CHARS from the end with a notice
-// in between. These are deliberate hardcoded defaults — see the design
-// discussion in notes/plan.md for the rationale.
-
-export const MAX_CHARS = 10_000;
-export const MAX_LINES = 500;
-export const HEAD_CHARS = 1500;
-export const TAIL_CHARS = 1500;
-
-/** Base directory for all tool-result spill files. Per-tab subdirectories live inside. */
-export const SPILL_ROOT = "/tmp/dispatch/tool-results";
-
-// ─── Public API ──────────────────────────────────────────────────
-
-export interface TruncationContext {
- /** Tab the tool call belongs to. Used to scope the spill directory. */
- tabId: string;
- /** Tool call ID, used as the spill file basename. */
- callId: string;
- /** Tool name, included in the truncation notice for human-readable hints. */
- toolName: string;
-}
-
-export interface TruncationResult {
- /** Final string sent to the model. Either the original (when under threshold) or the head+notice+tail excerpt. */
- displayResult: string;
- /** When truncation happened, the absolute path the full output was spilled to. Undefined otherwise. */
- spillPath?: string;
-}
-
-/**
- * Apply universal truncation to a tool result string.
- *
- * If the result is under both the character and line caps, returns it
- * unchanged with no side effects.
- *
- * If the result exceeds either cap:
- * 1. Writes the full content to `<SPILL_ROOT>/<tabId>/<callId>.txt`.
- * 2. Builds a display string consisting of HEAD_CHARS from the start,
- * a multi-line truncation notice that includes the spill path, and
- * TAIL_CHARS from the end.
- *
- * The notice instructs the model to use `read_file` (with offset/limit)
- * or `read_file_slice` to inspect the full content. Every tool result
- * flows through this function via `Agent.executeToolWithStreaming`, so
- * any new tool that returns a string automatically gets the protection.
- */
-export function applyTruncation(result: string, ctx: TruncationContext): TruncationResult {
- const totalChars = result.length;
- const totalLines = countLines(result);
-
- if (totalChars <= MAX_CHARS && totalLines <= MAX_LINES) {
- return { displayResult: result };
- }
-
- const spillPath = join(SPILL_ROOT, ctx.tabId, `${ctx.callId}.txt`);
- try {
- mkdirSync(dirname(spillPath), { recursive: true, mode: 0o700 });
- writeFileSync(spillPath, result, { encoding: "utf-8", mode: 0o600 });
- } catch (err) {
- // If we can't spill (disk full, perms, etc.) fall back to hard-truncating
- // the head + tail without a spill path reference. The model loses the
- // ability to inspect the middle but won't be blocked outright.
- const message = err instanceof Error ? err.message : String(err);
- return {
- displayResult: buildExcerpt(result, totalChars, totalLines, ctx, {
- spillPath: null,
- spillError: message,
- }),
- };
- }
-
- return {
- displayResult: buildExcerpt(result, totalChars, totalLines, ctx, {
- spillPath,
- spillError: null,
- }),
- spillPath,
- };
-}
-
-/** Delete the entire spill directory for a tab. Best-effort, errors swallowed. */
-export function clearSpillForTab(tabId: string): void {
- const dir = join(SPILL_ROOT, tabId);
- try {
- rmSync(dir, { recursive: true, force: true });
- } catch {
- // Ignore — tab close should not fail on cleanup errors
- }
-}
-
-// ─── Internal helpers ────────────────────────────────────────────
-
-function countLines(s: string): number {
- if (s.length === 0) return 0;
- let count = 1;
- for (let i = 0; i < s.length; i++) {
- if (s.charCodeAt(i) === 10 /* \n */) count++;
- }
- return count;
-}
-
-function buildExcerpt(
- result: string,
- totalChars: number,
- totalLines: number,
- ctx: TruncationContext,
- spill: { spillPath: string | null; spillError: string | null },
-): string {
- // Guard against pathological case where HEAD+TAIL overlap. If the result
- // is between MAX_CHARS and HEAD+TAIL (rare), slice cleanly so we don't
- // emit overlapping content.
- const head = result.slice(0, HEAD_CHARS);
- const tailStart = Math.max(HEAD_CHARS, totalChars - TAIL_CHARS);
- const tail = result.slice(tailStart);
-
- const omittedChars = Math.max(0, totalChars - head.length - tail.length);
-
- const notice: string[] = [
- "",
- `[output truncated by dispatch — tool=${ctx.toolName}, total ${totalChars.toLocaleString()} chars / ${totalLines.toLocaleString()} lines; showing first ${head.length.toLocaleString()} and last ${tail.length.toLocaleString()}, ${omittedChars.toLocaleString()} omitted]`,
- ];
- if (spill.spillPath) {
- notice.push(
- `[full output saved to: ${spill.spillPath}]`,
- `[use read_file with offset/limit (lines), or read_file_slice (chars within a single line), to inspect specific sections]`,
- );
- } else if (spill.spillError) {
- notice.push(`[failed to spill full output to disk: ${spill.spillError}]`);
- }
- notice.push("");
-
- return `${head}${notice.join("\n")}${tail}`;
-}
diff --git a/packages/core/src/tools/web-search.ts b/packages/core/src/tools/web-search.ts
deleted file mode 100644
index 7f061a5..0000000
--- a/packages/core/src/tools/web-search.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-import { z } from "zod";
-import type { ToolDefinition } from "../types/index.js";
-
-const FIRECRAWL_URL = "http://100.102.55.49:31329/v1/search";
-const MAX_OUTPUT_CHARS = 60000;
-const TIMEOUT_MS = 30000;
-
-export function createWebSearchTool(): ToolDefinition {
- return {
- name: "web_search",
- description:
- "Search the web via a self-hosted Firecrawl instance. Returns a list of results with titles, URLs, and descriptions. Optionally scrapes the full markdown content of each result page.",
- parameters: z.object({
- query: z.string().describe("The search query"),
- limit: z
- .number()
- .optional()
- .default(7)
- .describe("Maximum number of results to return (default 7)"),
- scrape: z
- .boolean()
- .optional()
- .default(false)
- .describe("Whether to also scrape the full markdown content of each result page"),
- lang: z.string().optional().describe('Language code to filter results (e.g. "en")'),
- country: z.string().optional().describe('Country code to filter results (e.g. "us")'),
- }),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const query = args.query as string;
- const limit = (args.limit as number | undefined) ?? 7;
- const scrape = (args.scrape as boolean | undefined) ?? false;
- const lang = args.lang as string | undefined;
- const country = args.country as string | undefined;
-
- const body: Record<string, unknown> = { query, limit };
- if (lang !== undefined) body.lang = lang;
- if (country !== undefined) body.country = country;
- if (scrape) {
- body.scrapeOptions = { formats: ["markdown"], onlyMainContent: true };
- }
-
- const controller = new AbortController();
- const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
-
- let response: Response;
- try {
- response = await fetch(FIRECRAWL_URL, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- Accept: "application/json",
- },
- body: JSON.stringify(body),
- signal: controller.signal,
- });
- } catch (err) {
- if (err instanceof Error && err.name === "AbortError") {
- return "Error: Request to Firecrawl timed out after 30 seconds.";
- }
- if (err instanceof Error && (err as NodeJS.ErrnoException).code === "ECONNREFUSED") {
- return `Error: Could not connect to Firecrawl at http://100.102.55.49:31329. Is it running?`;
- }
- return `Error: ${err instanceof Error ? err.message : String(err)}`;
- } finally {
- clearTimeout(timeout);
- }
-
- if (!response.ok) {
- const text = await response.text().catch(() => "");
- return `Error: Firecrawl returned HTTP ${response.status} ${response.statusText}${text ? `: ${text}` : ""}`;
- }
-
- let json: {
- data?: Array<{ title?: string; url?: string; description?: string; markdown?: string }>;
- };
- try {
- json = await response.json();
- } catch {
- return "Error: Failed to parse Firecrawl response as JSON";
- }
-
- const results = json.data ?? [];
- if (results.length === 0) {
- return "No results found.";
- }
-
- const parts: string[] = [];
- for (const result of results) {
- const title = result.title ?? "(no title)";
- const url = result.url ?? "";
- const description = result.description ?? "";
- let section = `### ${title}\n${url}\n\n${description}`;
- if (result.markdown) {
- section += `\n\n${result.markdown}`;
- }
- parts.push(section);
- }
-
- let output = parts.join("\n\n---\n\n");
- if (output.length > MAX_OUTPUT_CHARS) {
- output = `${output.slice(0, MAX_OUTPUT_CHARS)}\n\n[Output truncated]`;
- }
- return output;
- },
- };
-}
diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts
deleted file mode 100644
index 8a73352..0000000
--- a/packages/core/src/tools/write-file.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { mkdir, writeFile } from "node:fs/promises";
-import { dirname } from "node:path";
-import { z } from "zod";
-import type { ToolDefinition } from "../types/index.js";
-import { canonicalize } from "./path-utils.js";
-
-/**
- * Optional hook invoked AFTER a successful write, with the canonicalized
- * absolute path of the file just written. Its returned string (when non-empty)
- * is appended to the tool result. This is how LSP diagnostics are surfaced
- * back to the model on write without coupling `@dispatch/core`'s tools to the
- * API layer or the LSP manager — the host wires an implementation that touches
- * the file through the LSP and formats any diagnostics. Errors thrown here are
- * swallowed so a flaky LSP never fails the write itself.
- */
-export type AfterWriteHook = (absolutePath: string) => Promise<string>;
-
-export function createWriteFileTool(
- workingDirectory: string,
- onAfterWrite?: AfterWriteHook,
-): ToolDefinition {
- return {
- name: "write_file",
- description: "Write content to a file relative to the working directory.",
- parameters: z.object({
- path: z.string().describe("Path to the file, relative to the working directory"),
- content: z.string().describe("Content to write to the file"),
- }),
- execute: async (args: Record<string, unknown>): Promise<string> => {
- const filePath = args.path as string;
- const content = args.content as string;
- // Canonicalize so a workdir-relative path that resolves through
- // symlinks to outside the workdir is detected and blocked. The
- // canonicalize walks up to the nearest existing ancestor when the
- // leaf doesn't exist (typical for write_file), so a path like
- // `workdir/escape-link/new-file.txt` where `escape-link` symlinks
- // to /etc still resolves through the symlink and is caught here.
- const absolutePath = await canonicalize(workingDirectory, filePath);
- const absoluteWorkDir = await canonicalize(workingDirectory);
-
- if (absolutePath !== absoluteWorkDir && !absolutePath.startsWith(`${absoluteWorkDir}/`)) {
- return `Error: Path "${filePath}" is outside the working directory.`;
- }
-
- try {
- await mkdir(dirname(absolutePath), { recursive: true });
- await writeFile(absolutePath, content, "utf8");
- } catch (err) {
- return `Error writing file: ${err instanceof Error ? err.message : String(err)}`;
- }
-
- let result = `Successfully wrote to "${filePath}".`;
- // Post-write hook (e.g. LSP diagnostics). Best-effort: never let a
- // hook failure turn a successful write into an error.
- if (onAfterWrite) {
- try {
- const extra = await onAfterWrite(absolutePath);
- if (extra) result += `\n\n${extra}`;
- } catch {
- /* ignore — diagnostics are advisory */
- }
- }
- return result;
- },
- };
-}
diff --git a/packages/core/src/tools/youtube-transcribe.ts b/packages/core/src/tools/youtube-transcribe.ts
deleted file mode 100644
index 3a26d6f..0000000
--- a/packages/core/src/tools/youtube-transcribe.ts
+++ /dev/null
@@ -1,219 +0,0 @@
-import { randomUUID } from "node:crypto";
-import { z } from "zod";
-import type { ToolDefinition, ToolExecuteContext } from "../types/index.js";
-
-const TRANSCRIBER_BASE = "http://100.102.55.49:41090";
-const MAX_OUTPUT_CHARS = 60000;
-const REQUEST_TIMEOUT_MS = 30000;
-const MAX_WAIT_MS = 10 * 60 * 1000; // give up after 10 minutes of polling
-
-interface TranscriptResponse {
- status: string;
- video_id?: string;
- full_text?: string;
- segments?: Array<{ text: string; start: number; duration: number }>;
- position?: number;
- estimated_seconds?: number;
- error?: string;
- error_type?: string;
-}
-
-async function fetchTranscript(url: string): Promise<TranscriptResponse> {
- const controller = new AbortController();
- const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
- try {
- const apiUrl = `${TRANSCRIBER_BASE}/api/transcript?url=${encodeURIComponent(url)}`;
- const response = await fetch(apiUrl, { signal: controller.signal });
- if (!response.ok) {
- throw new Error(`Transcriber returned HTTP ${response.status} ${response.statusText}`);
- }
- return (await response.json()) as TranscriptResponse;
- } finally {
- clearTimeout(timeout);
- }
-}
-
-function formatTime(seconds: number): string {
- const mins = Math.floor(seconds / 60);
- const secs = Math.floor(seconds % 60);
- return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
-}
-
-function formatTranscript(data: TranscriptResponse): string {
- const segments = data.segments ?? [];
- const segmentsText = segments.map((seg) => `[${formatTime(seg.start)}] ${seg.text}`).join("\n");
-
- const output = [
- `Video ID: ${data.video_id}`,
- "",
- "## Transcript",
- "",
- data.full_text ?? "",
- "",
- "## Timestamped Segments",
- "",
- segmentsText,
- ].join("\n");
-
- return output.length > MAX_OUTPUT_CHARS
- ? `${output.slice(0, MAX_OUTPUT_CHARS)}\n\n[Transcript truncated]`
- : output;
-}
-
-function sleep(ms: number): Promise<void> {
- return new Promise((resolve) => setTimeout(resolve, ms));
-}
-
-/** Polls until the transcript is ready, fails, or times out. */
-async function pollUntilReady(url: string): Promise<string> {
- const startTime = Date.now();
-
- while (Date.now() - startTime < MAX_WAIT_MS) {
- const data = await fetchTranscript(url);
-
- if (data.status === "completed") {
- return formatTranscript(data);
- }
-
- if (data.status === "failed") {
- return `Error: Transcription failed for video ${data.video_id ?? "unknown"}: [${data.error_type ?? "unknown"}] ${data.error ?? "no details"}`;
- }
-
- if (data.status === "queued" || data.status === "processing") {
- const estimate = data.estimated_seconds ?? 30;
- const waitMs = Math.max((estimate - 2) * 1000, 2000);
- await sleep(waitMs);
- continue;
- }
-
- return `Error: Unexpected transcriber response status: ${data.status}`;
- }
-
- return "Error: Timed out waiting for transcript after 10 minutes.";
-}
-
-/** Store for transcript polls backgrounded due to user interrupt. */
-export class BackgroundTranscriptStore {
- private jobs = new Map<string, { url: string; completion: Promise<string> }>();
-
- register(url: string, completion: Promise<string>): string {
- const id = `youtube_transcribe_${randomUUID()}`;
- this.jobs.set(id, { url, completion });
- // Auto-cleanup 10 minutes after completion
- completion.finally(() => {
- setTimeout(() => this.jobs.delete(id), 10 * 60 * 1000);
- });
- return id;
- }
-
- async getResult(
- id: string,
- ): Promise<{ status: "done"; result: string } | { status: "error"; error: string }> {
- const job = this.jobs.get(id);
- if (!job) {
- return { status: "error", error: `No background transcript job found with id '${id}'` };
- }
- const result = await job.completion;
- return { status: "done", result };
- }
-
- has(id: string): boolean {
- return this.jobs.has(id);
- }
-}
-
-export function createYoutubeTranscribeTool(
- transcriptStore?: BackgroundTranscriptStore,
-): ToolDefinition {
- return {
- name: "youtube_transcribe",
- description: [
- "Fetch the transcript/subtitles for a YouTube video. This tool blocks until the transcript is ready.",
- "If the video hasn't been transcribed yet, it will be queued and this tool waits for it automatically.",
- "If the user interrupts while waiting, the request continues in the background and you receive a job ID.",
- "Use the retrieve tool with that ID to get the transcript later.",
- "",
- "Accepted URL formats:",
- " - youtube.com/watch?v=",
- " - youtu.be/",
- " - youtube.com/embed/",
- " - youtube.com/shorts/",
- ].join("\n"),
- parameters: z.object({
- url: z.string().describe("The YouTube video URL to fetch the transcript for."),
- background: z
- .boolean()
- .optional()
- .describe(
- "If true, the transcription request starts in the background and a job_id is returned immediately. Use the retrieve tool with the job_id to get the transcript later.",
- ),
- }),
- execute: async (
- args: Record<string, unknown>,
- context?: ToolExecuteContext,
- ): Promise<string> => {
- const url = args.url as string;
- const background = (args.background as boolean | undefined) ?? false;
- const queueCallbacks = context?.queueCallbacks;
-
- try {
- const pollPromise = pollUntilReady(url);
-
- // If background mode requested, register immediately and return job ID
- if (background && transcriptStore) {
- const jobId = transcriptStore.register(url, pollPromise);
- return [
- `Transcript request started in background.`,
- `job_id: ${jobId}`,
- `url: ${url}`,
- ``,
- `Use the retrieve tool with this job_id to get the transcript when ready.`,
- ].join("\n");
- }
-
- if (queueCallbacks && transcriptStore) {
- const { promise: queuePromise, cancel: cancelQueueWait } =
- queueCallbacks.waitForQueuedMessage();
- const queueSignal = queuePromise.then(() => "QUEUE_INTERRUPT" as const);
-
- const raceResult = await Promise.race([pollPromise, queueSignal]);
-
- if (raceResult === "QUEUE_INTERRUPT") {
- // Background the still-polling request
- const jobId = transcriptStore.register(url, pollPromise);
-
- const queuedMsgs = queueCallbacks.dequeueMessages();
- const userMessages = queuedMsgs.map((m) => m.message).join("\n---\n");
-
- return [
- `Transcript request backgrounded — still waiting for transcription.`,
- `job_id: ${jobId}`,
- `url: ${url}`,
- ``,
- `Use the retrieve tool with this job_id to get the transcript when ready.`,
- ``,
- `[USER INTERRUPT]`,
- `The user has sent you message(s) while you were working. You MUST address these before continuing with your current task:`,
- ``,
- userMessages,
- ].join("\n");
- }
-
- // Poll finished before interrupt
- cancelQueueWait();
- return raceResult;
- }
-
- return await pollPromise;
- } catch (err) {
- if (err instanceof Error && err.name === "AbortError") {
- return "Error: Request to YouTube transcriber timed out.";
- }
- if (err instanceof Error && (err as NodeJS.ErrnoException).code === "ECONNREFUSED") {
- return `Error: Could not connect to YouTube transcriber at ${TRANSCRIBER_BASE}. Is it running?`;
- }
- return `Error: ${err instanceof Error ? err.message : String(err)}`;
- }
- },
- };
-}
diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts
deleted file mode 100644
index e878813..0000000
--- a/packages/core/src/types/index.ts
+++ /dev/null
@@ -1,672 +0,0 @@
-import type { ZodType } from "zod";
-import type { PermissionChecker, Ruleset } from "../permission/index.js";
-
-// ─── Message Types ───────────────────────────────────────────────
-
-export type MessageRole = "user" | "assistant" | "system";
-
-/**
- * A single ordered chunk of content inside a message. The chunk list
- * preserves the actual temporal ordering of text, reasoning, tool calls,
- * system notices, and errors as they arrived from the model.
- *
- * Coalescing rules (see notes/plan-chunk-refactor.md):
- * - `text` and `thinking` coalesce on consecutive same-type deltas.
- * - `tool-batch` coalesces on consecutive `tool-call` events
- * (appends a new entry to `calls`).
- * - `error` and `system` are always single-event chunks (no coalescing).
- */
-export type Chunk = TextChunk | ThinkingChunk | ToolBatchChunk | ErrorChunk | SystemChunk;
-
-export interface TextChunk {
- type: "text";
- text: string;
-}
-
-export interface ThinkingChunk {
- type: "thinking";
- text: string;
- /**
- * Full Anthropic `providerMetadata` blob captured from the v6
- * `reasoning-end` stream event (typically `{ anthropic: { signature
- * } }` plus any other provider-side metadata). Round-tripped verbatim
- * as `providerOptions` on the `ReasoningPart` of the next request so
- * Anthropic can validate the thinking block's signature.
- *
- * Also acts as a "sealed" marker for `appendEventToChunks`: once
- * `metadata` is set, the next `reasoning-delta` opens a new thinking
- * chunk rather than extending this one (each Anthropic content block
- * gets its own metadata, so two consecutive thinking blocks must not
- * be coalesced).
- *
- * Optional: non-Anthropic models produce no metadata, and pre-v6
- * persisted chunks have neither field.
- */
- metadata?: Record<string, unknown>;
-}
-
-export interface ToolBatchChunk {
- type: "tool-batch";
- calls: ToolBatchEntry[];
-}
-
-export interface ToolBatchEntry {
- id: string;
- name: string;
- arguments: Record<string, unknown>;
- result?: string;
- isError?: boolean;
- shellOutput?: { stdout: string; stderr: string };
-}
-
-export interface ErrorChunk {
- type: "error";
- message: string;
- statusCode?: number;
-}
-
-export type SystemChunkKind = "notice" | "model-changed" | "config-reload" | "cancelled";
-
-export interface SystemChunk {
- type: "system";
- kind: SystemChunkKind;
- text: string;
-}
-
-export interface ChatMessage {
- role: MessageRole;
- chunks: Chunk[];
- /**
- * Ephemeral ORDERED multimodal content for a user turn (interleaved text +
- * image/pdf attachments). Set ONLY transiently on the in-flight user message
- * so `toModelMessages` can emit multimodal `ImagePart`/`FilePart` content to
- * the provider. Never persisted (the chunk log stores only the text, with
- * `[image]`/`[pdf]` markers), so it's absent on history-rebuilt messages.
- * When absent, the message is plain text built from its `chunks`.
- */
- content?: UserContentPart[];
-}
-
-// ─── Multimodal user content (image / PDF attachments) ───────────
-//
-// When a user pastes one or more images/PDFs into the chat input, the turn's
-// user message carries an ORDERED list of content parts instead of a plain
-// string. The ordering is meaningful — the user can interleave text and
-// attachments ("here is image A: <A>, here is image B: <B>") and the model
-// sees them in exactly that sequence.
-//
-// These parts are EPHEMERAL: they are forwarded to the model for the turn that
-// produced them but are NOT persisted as raw bytes in the chunk log. History
-// stores only the user's text (with `[image]` / `[pdf]` markers in place of
-// each attachment), so a later reload re-renders the text but never re-sends
-// the binary payload. This keeps the persisted log small and avoids re-billing
-// image tokens on every subsequent turn.
-
-/** A plain-text segment of a multimodal user message. */
-export interface UserTextPart {
- type: "text";
- text: string;
-}
-
-/**
- * A binary attachment (image or PDF) in a multimodal user message. `data` is a
- * base64-encoded payload (no `data:` URI prefix); `mediaType` is the IANA media
- * type (e.g. `image/png`, `application/pdf`). `name` is an optional original
- * filename, used only for PDF `filename` passthrough and diagnostics.
- */
-export interface UserAttachmentPart {
- type: "attachment";
- /** IANA media type, e.g. `image/png`, `image/jpeg`, `application/pdf`. */
- mediaType: string;
- /** Base64-encoded bytes WITHOUT a `data:` URI prefix. */
- data: string;
- /** Optional original filename (mainly for PDFs). */
- name?: string;
-}
-
-/** One ordered part of a multimodal user message. */
-export type UserContentPart = UserTextPart | UserAttachmentPart;
-
-// ─── Append-only chunk log (persisted model) ─────────────────────
-//
-// The DB stores a conversation as a flat stream of `ChunkRow`s (see
-// db/chunks.ts). The render-facing `Chunk`/`ChatMessage` shapes above are
-// DERIVED from these rows by grouping (turn_id + step + role). Tool calls
-// and their results are SEPARATE rows linked by `callId`, mapping 1:1 to the
-// Anthropic wire format.
-
-/** Role of a persisted chunk row. `tool` rows hold tool results. */
-export type ChunkRole = "user" | "assistant" | "tool" | "system";
-
-/** Discriminator for a persisted chunk row's payload. */
-export type ChunkType =
- | "text"
- | "thinking"
- | "tool_call"
- | "tool_result"
- | "error"
- | "system"
- | "usage";
-
-export interface TextData {
- text: string;
-}
-export interface ThinkingData {
- text: string;
- metadata?: Record<string, unknown>;
-}
-export interface ToolCallData {
- callId: string;
- name: string;
- arguments: Record<string, unknown>;
-}
-export interface ToolResultData {
- callId: string;
- name: string;
- result: string;
- isError: boolean;
- shellOutput?: { stdout: string; stderr: string };
-}
-export interface ErrorData {
- message: string;
- statusCode?: number;
-}
-export interface SystemData {
- kind: SystemChunkKind;
- text: string;
-}
-/**
- * Per-request token usage persisted as a SIDE-CHANNEL chunk row (one row per
- * `usage` AgentEvent, i.e. one per LLM round-trip). These rows are deliberately
- * EXCLUDED from `getChunksForTab`/`getTotalChunkCount` so they never enter the
- * render, pagination, eviction, or agent-history-rebuild paths — they exist
- * only to feed the backend aggregate `getUsageStatsForTab`, which seeds the
- * frontend's `cacheStats` on reload. `inputTokens` is the TOTAL prompt
- * (cached + fresh); `cacheReadTokens`/`cacheWriteTokens` are Anthropic's
- * prompt-cache split. Mirrors the `usage` AgentEvent payload.
- */
-export interface UsageData {
- inputTokens: number;
- outputTokens: number;
- cacheReadTokens: number;
- cacheWriteTokens: number;
-}
-
-/**
- * Aggregate per-tab usage telemetry: the cumulative sum across ALL persisted
- * `usage` rows, the request count, and the most recent request's split. This is
- * the server-side source of truth (complete regardless of frontend
- * eviction/pagination) returned by `getUsageStatsForTab`. Structurally
- * identical to the frontend `CacheStats` so it can seed it directly. `null` when
- * the tab has no usage rows.
- */
-export interface UsageStats {
- inputTokens: number;
- outputTokens: number;
- cacheReadTokens: number;
- cacheWriteTokens: number;
- /** Number of LLM requests (usage rows) counted. */
- requests: number;
- last: {
- inputTokens: number;
- outputTokens: number;
- cacheReadTokens: number;
- cacheWriteTokens: number;
- } | null;
-}
-
-export type ChunkData =
- | TextData
- | ThinkingData
- | ToolCallData
- | ToolResultData
- | ErrorData
- | SystemData
- | UsageData;
-
-/**
- * A persisted chunk row — the append-only unit of conversation storage and
- * the unit of frontend pagination. `seq` is per-tab monotonic and is both the
- * ordering key and the pagination cursor.
- */
-export interface ChunkRow {
- id: string;
- tabId: string;
- seq: number;
- turnId: string;
- step: number;
- role: ChunkRole;
- type: ChunkType;
- data: ChunkData;
- createdAt: number;
-}
-
-/**
- * A chunk-row draft (no `seq`/`tabId`/`createdAt`/`id` yet) used when
- * exploding an in-memory turn into rows for persistence.
- */
-export interface ChunkRowDraft {
- turnId: string;
- step: number;
- role: ChunkRole;
- type: ChunkType;
- data: ChunkData;
-}
-
-export interface ToolCall {
- id: string;
- name: string;
- arguments: Record<string, unknown>;
-}
-
-export interface ToolResult {
- toolCallId: string;
- toolName: string;
- result: string;
- isError: boolean;
-}
-
-// ─── Agent Status & Events ───────────────────────────────────────
-
-export type AgentStatus = "idle" | "running" | "error" | "waiting_for_key";
-
-/**
- * Per-tab snapshot of live state, sent on WS connect and via
- * `GET /status`. Carries enough information for a freshly-loaded
- * frontend to reconstruct any in-flight assistant message.
- *
- * - `status` — always present; mirrors the in-memory `TabAgent.status`.
- * - `currentChunks` — the live in-flight `Chunk[]` for the running
- * assistant turn. Present iff `status === "running"` AND
- * `TabAgent.currentChunks` is non-null. Defensively copied at
- * snapshot time; the consumer owns the array.
- * - `currentAssistantId` — DB id of the in-flight assistant message
- * (the row that the eventual `flushAssistant` call will write/update).
- * Present iff `status === "running"` AND `TabAgent.currentAssistantId`
- * is set. The frontend uses this to align its local assistant
- * message id with the persisted id so subsequent `done` and reload
- * paths line up.
- *
- * Not part of `AgentEvent` itself: the `statuses` payload is a WS-
- * connect-level snapshot, not an event the `Agent` emits. The frontend
- * mirrors this type in `packages/frontend/src/lib/types.ts`.
- */
-export interface TabStatusSnapshot {
- status: AgentStatus;
- currentChunks?: Chunk[];
- currentAssistantId?: string;
- /**
- * `turn_id` of the in-flight turn. Present iff `status === "running"`.
- * Lets a frontend that reconnects mid-stream key its live chunks the same
- * way `turn-start` would, so they reconcile cleanly when the turn seals.
- */
- currentTurnId?: string;
- /**
- * The tab's current todo list. Included for ALL tabs (not just running
- * ones) so a freshly-reloaded frontend rehydrates the Tasks panel from the
- * backend instead of blanking it. Omitted when the list is empty.
- */
- tasks?: TaskItem[];
-}
-
-export type AgentEvent =
- | { type: "status"; status: AgentStatus }
- /**
- * Emitted once at the start of a turn (`processMessage`), before any
- * content deltas. Carries the `turn_id` shared by this turn's user message
- * and every assistant/tool chunk row. The frontend tags its in-flight
- * (live) chunks with this id so they key-match the sealed rows on
- * turn-completion reconcile (no remount/flicker). Display/sync only — not
- * conversation content.
- */
- | { type: "turn-start"; turnId: string }
- /**
- * Emitted once after a turn has fully settled AND its chunks have been
- * persisted (after `flushAssistant`). Signals the frontend that the turn's
- * rows — with real `seq`s — are now durable and can be reloaded, so it can
- * fold its transient live representation into the sealed chunk log. Emitted
- * after `status: idle`/`error` (which fire before the DB write). Display/sync
- * only — not conversation content.
- *
- * Carries `usageStats`: the tab's authoritative usage aggregate read from the
- * DB AFTER the turn's usage rows were written. The frontend REPLACES (not adds)
- * its live `cacheStats` with this, reconciling the live accumulator to the
- * persisted truth every turn. This self-heals the live overshoot that occurs
- * when a rate-limited fallback attempt's usage is streamed live but then
- * discarded server-side (never persisted). `null` ⇒ tab has no usage rows;
- * absent ⇒ leave `cacheStats` untouched (back-compat).
- */
- | { type: "turn-sealed"; turnId: string; usageStats?: UsageStats | null }
- | { type: "text-delta"; delta: string }
- | { type: "reasoning-delta"; delta: string }
- /**
- * Emitted on the v6 `reasoning-end` stream event when it carries
- * `providerMetadata`. `appendEventToChunks` attaches the metadata to
- * the most recent unsealed `thinking` chunk; `toModelMessages` reads
- * it back as `providerOptions` on the next request's `ReasoningPart`.
- */
- | { type: "reasoning-end"; metadata?: Record<string, unknown> }
- | { type: "tool-call"; toolCall: ToolCall }
- | { type: "tool-result"; toolResult: ToolResult }
- | { type: "shell-output"; data: string; stream: "stdout" | "stderr" }
- /**
- * Per-request token usage, emitted once per LLM round-trip (each
- * `streamText` step) from the AI SDK `finish` stream event. `inputTokens`
- * is the TOTAL prompt size including cached tokens; `cacheReadTokens` is
- * the portion served from Anthropic's prompt cache (a cache HIT) and
- * `cacheWriteTokens` the portion written to it (a cache seed). The "Cache
- * Rate" view aggregates these to show the prompt-cache hit rate. Non-
- * caching providers report zero for the cache fields.
- */
- | {
- type: "usage";
- usage: {
- inputTokens: number;
- outputTokens: number;
- cacheReadTokens: number;
- cacheWriteTokens: number;
- };
- }
- | { type: "error"; error: string; statusCode?: number }
- | { type: "notice"; message: string }
- | { type: "model-changed"; keyId: string; modelId: string }
- | { type: "done"; message: ChatMessage }
- | { type: "task-list-update"; tasks: TaskItem[] }
- | { type: "config-reload" }
- | {
- type: "tab-created";
- id: string;
- title: string;
- keyId: string | null;
- modelId: string | null;
- parentTabId: string | null;
- agentSlug?: string | null;
- workingDirectory: string | null;
- agentModels?: Array<{ key_id: string; model_id: string }> | null;
- }
- | { type: "message-queued"; tabId: string; messageId: string; message: string }
- | {
- type: "message-consumed";
- tabId: string;
- messageIds: string[];
- /**
- * Why the queue was drained:
- * - "interrupt": consumed mid-turn, folded into a running turn's tool
- * result as a [USER INTERRUPT]. The optimistic bubble collapses into
- * that sealed turn.
- * - "continuation": consumed between turns to START a new turn. The
- * optimistic bubble becomes that new turn's initiating user row.
- * Absent ⇒ treat as "interrupt" (back-compat).
- */
- reason?: "interrupt" | "continuation";
- }
- | { type: "message-cancelled"; tabId: string; messageId: string }
- /**
- * Conversation-compaction lifecycle (UI-driven, not an agent tool). A
- * compaction summarizes a tab's older history into an anchored summary while
- * preserving the most recent turns verbatim.
- *
- * `compaction-started` fires on the temporary placeholder tab when the
- * summary request begins. `compaction-complete` fires when the summary has
- * been generated and the history relocated: the compacted continuation now
- * lives on `sourceTabId` (the canonical id, with its key/model/working-dir
- * preserved), the FULL pre-compaction history was moved to `backupTabId`, and
- * `tempTabId` (the placeholder) should be discarded by the frontend.
- * `compaction-error` reports a failure (or cancellation) on `tempTabId`.
- */
- | { type: "compaction-started"; tempTabId: string; sourceTabId: string }
- | {
- type: "compaction-complete";
- tempTabId: string;
- sourceTabId: string;
- backupTabId: string;
- backupTitle: string;
- }
- | { type: "compaction-error"; tempTabId: string; sourceTabId: string; error: string };
-
-// ─── Tool Types ──────────────────────────────────────────────────
-
-export interface ToolExecuteContext {
- onOutput?: (data: string, stream: "stdout" | "stderr") => void;
- queueCallbacks?: QueueCallbacks;
-}
-
-export interface ToolDefinition {
- name: string;
- description: string;
- parameters: ZodType;
- execute: (args: Record<string, unknown>, context?: ToolExecuteContext) => Promise<string>;
-}
-
-// ─── Agent Configuration ─────────────────────────────────────────
-
-/**
- * Canonical, ordered list of reasoning-effort levels — the SINGLE SOURCE OF
- * TRUTH for effort values across the whole codebase (core LLM call site, API
- * validation, agent TOML persistence, and the frontend UI). Ordered from least
- * to most effort.
- *
- * `none` disables reasoning. `low`/`medium`/`high`/`xhigh` are forwarded
- * verbatim to providers that accept them (OpenAI-compatible `reasoning_effort`,
- * Anthropic adaptive `effort`) — `xhigh` is accepted by newer OpenAI reasoning
- * models. `max` is Dispatch's own top tier, mapped per-provider at the call
- * site (e.g. classic-thinking Claude budget tokens).
- */
-export const REASONING_EFFORTS = ["none", "low", "medium", "high", "xhigh", "max"] as const;
-
-export type ReasoningEffort = (typeof REASONING_EFFORTS)[number];
-
-/**
- * Default effort applied when nothing more specific is configured (no per-model
- * effort and no per-tab selection). Resolution order is
- * per-model → per-tab → this default.
- */
-export const DEFAULT_REASONING_EFFORT: ReasoningEffort = "high";
-
-/** Human-readable labels for each effort level (UI display). */
-export const REASONING_EFFORT_LABELS: Record<ReasoningEffort, string> = {
- none: "Off",
- low: "Low",
- medium: "Medium",
- high: "High",
- xhigh: "X-High",
- max: "Max",
-};
-
-/** Runtime type guard for narrowing an arbitrary value to a `ReasoningEffort`. */
-export function isReasoningEffort(value: unknown): value is ReasoningEffort {
- return typeof value === "string" && (REASONING_EFFORTS as readonly string[]).includes(value);
-}
-
-export interface AgentConfig {
- model: string;
- apiKey: string;
- baseURL: string;
- systemPrompt: string;
- tools: ToolDefinition[];
- workingDirectory: string;
- permissionChecker?: PermissionChecker;
- ruleset?: Ruleset;
- reasoningEffort?: ReasoningEffort;
- provider?: string;
- claudeCredentials?: {
- accessToken: string;
- };
- /**
- * Tab ID the agent runs on. Used to scope per-tab side effects, namely
- * the tool-output spill directory (`/tmp/dispatch/tool-results/<tabId>/`).
- * Optional so legacy callers and tests can construct an Agent without one;
- * a fallback ID is generated when absent.
- */
- tabId?: string;
-}
-
-// ─── Config Types (dispatch.toml) ────────────────────────────────
-
-export interface DispatchConfig {
- keys?: KeyDefinition[];
- permissions: Record<string, string | Record<string, string>>;
- /**
- * Language Server Protocol servers, keyed by an arbitrary server id (e.g.
- * `"luau-lsp"`). Resolved by merging the HOME-directory global
- * `dispatch.toml` (`~/.config/dispatch/dispatch.toml`) underneath the
- * `dispatch.toml` in a tab's effective working directory — local entries
- * override global ones sharing the same id, and global-only servers stay
- * active in every repository. Re-consulted when either config (or the
- * directory) changes. Config-driven only — there is no builtin server
- * registry and no auto-download; the declared `command[0]` must be on PATH.
- */
- lsp?: Record<string, LspServerConfig>;
-}
-
-/**
- * A single LSP server entry as expressed in `dispatch.toml`'s `[lsp.<id>]`
- * block. Mirrors opencode's custom-server schema so the Roblox Luau config
- * (and any other server) is portable between the two.
- *
- * Example (`dispatch.toml`):
- * ```toml
- * [lsp.luau-lsp]
- * command = ["luau-lsp", "lsp", "--definitions=globalTypes.d.luau", "--docs=api-docs.json"]
- * extensions = [".luau"]
- *
- * [lsp.luau-lsp.initialization.luau-lsp.platform]
- * type = "roblox"
- * ```
- */
-export interface LspServerConfig {
- /**
- * Argv to launch the server over stdio. `command[0]` is the executable
- * (resolved via PATH); the rest are arguments. Required for every non-
- * disabled entry.
- */
- command: string[];
- /**
- * File extensions (with leading dot, e.g. `".luau"`) this server attaches
- * to. Required for custom servers — without it the client never knows which
- * files should activate the server.
- */
- extensions: string[];
- /** Extra environment variables merged onto `process.env` for the child. */
- env?: Record<string, string>;
- /**
- * `initializationOptions` forwarded verbatim in the LSP `initialize`
- * request (and echoed back for `workspace/configuration` /
- * `didChangeConfiguration`). For luau-lsp this carries the
- * `{ "luau-lsp": { platform, sourcemap, types, diagnostics, ... } }` block.
- */
- initialization?: Record<string, unknown>;
- /** When true, the entry is parsed but skipped (no server launched). */
- disabled?: boolean;
-}
-
-export interface KeyDefinition {
- id: string;
- provider: string;
- env?: string;
- base_url: string;
- /** For "anthropic" provider: path to credentials file (default: ~/.claude/.credentials.json) */
- credentials_file?: string;
-}
-
-export type KeyStatus = "active" | "exhausted";
-
-export interface KeyState {
- definition: KeyDefinition;
- status: KeyStatus;
- lastError?: string;
- exhaustedAt?: number;
-}
-
-// ─── Skills Types ────────────────────────────────────────────────
-
-export type SkillScope = "global" | "project";
-export type SkillDirectory = string;
-
-export interface SkillDefinition {
- name: string;
- description: string;
- tags: string[];
- content: string;
- scope: SkillScope;
- source: string;
- directory: SkillDirectory;
-}
-
-export interface AgentSkillMapping {
- agentType: string;
- isOrchestrator: boolean;
- skills: string[];
- scope: SkillScope;
-}
-
-// ─── Task List Types ─────────────────────────────────────────────
-
-export type TaskStatus = "pending" | "in_progress" | "completed" | "cancelled";
-
-export interface TaskItem {
- /**
- * Stable positional id used purely for UI keying and the
- * `task-list-update` event contract. It is NEVER exposed to the model:
- * the `todo` tool is a declarative whole-list write (the model sends the
- * entire desired list every call), so there are no ids for the model to
- * track. Ids are reassigned positionally on every `setTasks`.
- */
- id: string;
- content: string;
- status: TaskStatus;
-}
-
-// ─── Config Validation ───────────────────────────────────────────
-
-export interface ConfigError {
- path: string;
- message: string;
-}
-
-// ─── Message Queue Types ─────────────────────────────────────────
-
-export interface QueuedMessage {
- id: string;
- message: string;
- timestamp: number;
-}
-
-export interface QueueCallbacks {
- dequeueMessages: () => QueuedMessage[];
- waitForQueuedMessage: () => { promise: Promise<void>; cancel: () => void };
-}
-
-// ─── Agent Definition Types ──────────────────────────────────────
-
-export interface AgentModelEntry {
- key_id: string;
- model_id: string;
- /**
- * Per-model/key reasoning effort. When set, overrides the per-tab effort
- * selector for generations that use this entry (resolution order:
- * per-model → per-tab → DEFAULT_REASONING_EFFORT). Omitted when unset.
- */
- effort?: ReasoningEffort;
-}
-
-export interface AgentDefinition {
- /** Human-readable name */
- name: string;
- /** Short description of what this agent does */
- description: string;
- /** Skills to auto-include, as "scope:name" strings */
- skills: string[];
- /** Allowed tools (allowlist) */
- tools: string[];
- /** Key+model fallback hierarchy, tried in order */
- models: AgentModelEntry[];
- /** Where the TOML was loaded from: "global" or a directory path */
- scope: string;
- /** The slug (filename without .toml) */
- slug: string;
- /** Default working directory for this agent (optional, absolute path) */
- cwd?: string;
- /** Whether this agent is a subagent (hidden from Chat Settings) */
- is_subagent?: boolean;
-}
diff --git a/packages/core/tests/agent/agent.test.ts b/packages/core/tests/agent/agent.test.ts
deleted file mode 100644
index 797aea2..0000000
--- a/packages/core/tests/agent/agent.test.ts
+++ /dev/null
@@ -1,1791 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import { z } from "zod";
-import type { AgentConfig, AgentEvent } from "../../src/types/index.js";
-
-// Mock bun:sqlite to avoid Bun-only import in vitest/Node
-vi.mock("../../src/db/index.js", () => ({
- getDatabase: vi.fn(() => ({})),
-}));
-
-// Mock the credentials module that depends on the DB
-vi.mock("../../src/credentials/claude.js", () => ({
- buildBillingHeaderValue: vi.fn(() => ""),
- SYSTEM_IDENTITY: "You are a test agent.",
-}));
-
-// Mock the ai module's streamText
-vi.mock("ai", async () => {
- const actual = await import("ai");
- return {
- ...actual,
- streamText: vi.fn(),
- };
-});
-
-// Mock the provider
-vi.mock("@ai-sdk/openai-compatible", () => ({
- createOpenAICompatible: vi.fn(() => (_model: string) => ({
- type: "language-model",
- modelId: _model,
- })),
-}));
-
-const { Agent, anthropicThinkingProviderOptions } = await import("../../src/agent/agent.js");
-const { streamText } = await import("ai");
-
-function makeConfig(overrides: Partial<AgentConfig> = {}): AgentConfig {
- return {
- model: "test-model",
- apiKey: "test-key",
- baseURL: "https://example.com/v1",
- systemPrompt: "You are a helpful assistant.",
- tools: [],
- workingDirectory: "/tmp",
- ...overrides,
- };
-}
-
-async function* makeFullStream(
- events: Array<{ type: string; [key: string]: unknown }>,
-): AsyncGenerator<{ type: string; [key: string]: unknown }> {
- for (const event of events) {
- yield event;
- }
-}
-
-function makeMockStreamResult(events: Array<{ type: string; [key: string]: unknown }>) {
- return {
- fullStream: makeFullStream(events),
- } as ReturnType<typeof import("ai").streamText>;
-}
-
-// v6 finish event — only finishReason, rawFinishReason, totalUsage (no usage/providerMetadata/response)
-const finishStop = {
- type: "finish",
- finishReason: "stop",
- rawFinishReason: "stop",
- totalUsage: { inputTokens: 10, outputTokens: 5 },
-};
-
-const finishToolCalls = {
- type: "finish",
- finishReason: "tool-calls",
- rawFinishReason: "tool_use",
- totalUsage: { inputTokens: 10, outputTokens: 5 },
-};
-
-describe("Agent", () => {
- it("starts in idle status", () => {
- const agent = new Agent(makeConfig());
- expect(agent.status).toBe("idle");
- });
-
- it("has empty messages initially", () => {
- const agent = new Agent(makeConfig());
- expect(agent.messages).toHaveLength(0);
- });
-
- it("yields running then idle status events around a simple message", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([
- // v6: text-delta uses `text` (not `textDelta`)
- { type: "text-delta", id: "t0", text: "Hello!" },
- finishStop,
- ]),
- );
-
- const agent = new Agent(makeConfig());
- const events = [];
- for await (const event of agent.run("hi")) {
- events.push(event);
- }
-
- const types = events.map((e) => e.type);
- expect(types[0]).toBe("status");
- expect(events[0]).toMatchObject({ type: "status", status: "running" });
-
- const lastStatusEvent = events.filter((e) => e.type === "status").at(-1);
- expect(lastStatusEvent).toMatchObject({ type: "status", status: "idle" });
- });
-
- it("yields text-delta events", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([
- // v6: text-delta uses `text` (not `textDelta`)
- { type: "text-delta", id: "t0", text: "Hello" },
- { type: "text-delta", id: "t0", text: " world" },
- finishStop,
- ]),
- );
-
- const agent = new Agent(makeConfig());
- const events = [];
- for await (const event of agent.run("test")) {
- events.push(event);
- }
-
- const textDeltas = events.filter((e) => e.type === "text-delta");
- expect(textDeltas).toHaveLength(2);
- expect(textDeltas[0]).toMatchObject({ delta: "Hello" });
- expect(textDeltas[1]).toMatchObject({ delta: " world" });
- });
-
- it("adds user message and assistant message to history", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "Response" }, finishStop]),
- );
-
- const agent = new Agent(makeConfig());
- for await (const _ of agent.run("my question")) {
- // consume generator
- }
-
- expect(agent.messages).toHaveLength(2);
- expect(agent.messages[0]).toMatchObject({
- role: "user",
- chunks: [{ type: "text", text: "my question" }],
- });
- expect(agent.messages[1]).toMatchObject({
- role: "assistant",
- chunks: [{ type: "text", text: "Response" }],
- });
- });
-
- it("yields done event with final message", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "Done!" }, finishStop]),
- );
-
- const agent = new Agent(makeConfig());
- const events = [];
- for await (const event of agent.run("test")) {
- events.push(event);
- }
-
- const doneEvent = events.find((e) => e.type === "done");
- expect(doneEvent).toBeDefined();
- expect(doneEvent).toMatchObject({
- type: "done",
- message: { role: "assistant", chunks: [{ type: "text", text: "Done!" }] },
- });
- });
-
- it("yields tool-call and tool-result events", async () => {
- // First call: LLM emits a tool-call
- // Second call (after tool execution): LLM emits text response with no tool calls
- vi.mocked(streamText)
- .mockReturnValueOnce(
- makeMockStreamResult([
- {
- type: "tool-call",
- toolCallId: "tc1",
- toolName: "read_file",
- // v6: `input` replaces `args`
- input: { path: "hello.txt" },
- },
- finishToolCalls,
- ]),
- )
- .mockReturnValueOnce(
- makeMockStreamResult([
- { type: "text-delta", id: "t0", text: "Here is the file." },
- finishStop,
- ]),
- );
-
- const toolDef = {
- name: "read_file",
- description: "reads a file",
- parameters: z.object({ path: z.string() }),
- execute: async (_args: Record<string, unknown>) => "file contents",
- };
-
- const agent = new Agent(makeConfig({ tools: [toolDef] }));
- const events = [];
- for await (const event of agent.run("read the file")) {
- events.push(event);
- }
-
- const toolCallEvent = events.find((e) => e.type === "tool-call");
- expect(toolCallEvent).toMatchObject({
- type: "tool-call",
- toolCall: { id: "tc1", name: "read_file" },
- });
-
- const toolResultEvent = events.find((e) => e.type === "tool-result");
- expect(toolResultEvent).toMatchObject({
- type: "tool-result",
- toolResult: { toolCallId: "tc1", result: "file contents" },
- });
- });
-
- it("does NOT swallow trailing queued messages into history at turn end", async () => {
- // Regression for the "queue not consumed after the turn ends" bug. A
- // message that lands on the queue after the last tool call (here: a
- // no-tool turn) must be LEFT on the queue for the orchestrator to start
- // a new turn — not silently appended to history with no response.
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "done" }, finishStop]),
- );
-
- const queue = [{ id: "q1", message: "answer me next", timestamp: 1 }];
- const dequeueMessages = vi.fn(() => queue.splice(0, queue.length));
- const agent = new Agent(makeConfig(), {
- dequeueMessages,
- waitForQueuedMessage: () => ({ promise: Promise.resolve(), cancel: () => {} }),
- });
-
- const before = agent.messages.length;
- for await (const _ of agent.run("hello")) {
- // consume
- }
-
- // The agent appended exactly the user turn + its own assistant reply;
- // it did NOT drain the queue or append a trailing user message for it.
- expect(dequeueMessages).not.toHaveBeenCalled();
- expect(queue).toHaveLength(1);
- const added = agent.messages.slice(before);
- expect(added.map((m) => m.role)).toEqual(["user", "assistant"]);
- expect(
- added.some((m) => m.chunks.some((c) => c.type === "text" && c.text === "answer me next")),
- ).toBe(false);
- });
-
- it("still injects a mid-turn queued message into the last tool result", async () => {
- // The interrupt path (site 1) must be untouched by the turn-end fix: a
- // message present DURING a tool batch is folded into that batch's last
- // tool result as a [USER INTERRUPT], and the agent loops back to the LLM.
- vi.mocked(streamText)
- .mockReturnValueOnce(
- makeMockStreamResult([
- { type: "tool-call", toolCallId: "tc1", toolName: "read_file", input: { path: "a.txt" } },
- finishToolCalls,
- ]),
- )
- .mockReturnValueOnce(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- const queue = [{ id: "q1", message: "stop and do X", timestamp: 1 }];
- const dequeueMessages = vi.fn(() => queue.splice(0, queue.length));
- const toolDef = {
- name: "read_file",
- description: "reads a file",
- parameters: z.object({ path: z.string() }),
- execute: async () => "file contents",
- };
- const agent = new Agent(makeConfig({ tools: [toolDef] }), {
- dequeueMessages,
- waitForQueuedMessage: () => ({ promise: Promise.resolve(), cancel: () => {} }),
- });
-
- const events: AgentEvent[] = [];
- for await (const event of agent.run("read it")) {
- events.push(event);
- }
-
- expect(dequeueMessages).toHaveBeenCalled();
- const toolResult = events.find((e) => e.type === "tool-result") as
- | (AgentEvent & { toolResult: { result: string } })
- | undefined;
- expect(toolResult?.toolResult.result).toContain("[USER INTERRUPT]");
- expect(toolResult?.toolResult.result).toContain("stop and do X");
- });
-
- it("yields reasoning-delta events", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([
- // v6: reasoning-delta uses `text` (not `textDelta`)
- { type: "reasoning-delta", id: "r0", text: "thinking about this..." },
- { type: "reasoning-delta", id: "r0", text: " more thoughts" },
- { type: "text-delta", id: "t0", text: "Answer" },
- finishStop,
- ]),
- );
-
- const agent = new Agent(makeConfig());
- const events = [];
- for await (const event of agent.run("think")) {
- events.push(event);
- }
-
- const reasoningDeltas = events.filter((e) => e.type === "reasoning-delta");
- expect(reasoningDeltas).toHaveLength(2);
- expect(reasoningDeltas[0]).toMatchObject({ delta: "thinking about this..." });
- expect(reasoningDeltas[1]).toMatchObject({ delta: " more thoughts" });
- });
-
- it("yields reasoning-end event when providerMetadata is present", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([
- { type: "reasoning-delta", id: "r0", text: "some reasoning" },
- {
- type: "reasoning-end",
- id: "r0",
- providerMetadata: { anthropic: { signature: "sig-1" } },
- },
- { type: "text-delta", id: "t0", text: "Answer" },
- finishStop,
- ]),
- );
-
- const agent = new Agent(makeConfig());
- const events = [];
- for await (const event of agent.run("think")) {
- events.push(event);
- }
-
- const reasoningEndEvent = events.find((e) => e.type === "reasoning-end");
- expect(reasoningEndEvent).toBeDefined();
- expect(reasoningEndEvent).toMatchObject({
- type: "reasoning-end",
- metadata: { anthropic: { signature: "sig-1" } },
- });
- });
-
- it("does NOT yield reasoning-end event when providerMetadata is absent", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([
- { type: "reasoning-delta", id: "r0", text: "some reasoning" },
- {
- type: "reasoning-end",
- id: "r0",
- // No providerMetadata — non-Anthropic model
- },
- { type: "text-delta", id: "t0", text: "Answer" },
- finishStop,
- ]),
- );
-
- const agent = new Agent(makeConfig());
- const events = [];
- for await (const event of agent.run("think")) {
- events.push(event);
- }
-
- const reasoningEndEvent = events.find((e) => e.type === "reasoning-end");
- expect(reasoningEndEvent).toBeUndefined();
- });
-
- // ─── New v6 round-trip tests ──────────────────────────────────────────────
-
- it("signed thinking round-trip: ThinkingChunk.metadata → ReasoningPart.providerOptions", async () => {
- // Pre-seed the agent with a prior assistant message containing a ThinkingChunk
- // with metadata (the Anthropic signature blob).
- // Anthropic-path provider — for openai-compatible the metadata
- // would be lifted into providerOptions.openaiCompatible instead;
- // that path is covered by the DeepSeek tests further down.
- const agent = new Agent(makeConfig({ provider: "opencode-anthropic" }));
- agent.messages.push({
- role: "user",
- chunks: [{ type: "text", text: "prior user message" }],
- });
- agent.messages.push({
- role: "assistant",
- chunks: [
- {
- type: "thinking",
- text: "I thought about it",
- metadata: { anthropic: { signature: "S" } },
- },
- { type: "text", text: "prior response" },
- ],
- });
-
- // Next turn: just return a simple text response
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "New answer" }, finishStop]),
- );
-
- for await (const _ of agent.run("follow-up")) {
- // consume
- }
-
- // Inspect the messages passed to streamText in this (last) call
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- expect(callArgs).toBeDefined();
- const messages = callArgs?.messages as Array<{
- role: string;
- content: unknown;
- }>;
-
- // Find the assistant message in the rebuilt ModelMessage[]
- const assistantMsg = messages.find((m) => m.role === "assistant");
- expect(assistantMsg).toBeDefined();
- const content = assistantMsg?.content as Array<Record<string, unknown>>;
- const reasoningPart = content.find((p) => p.type === "reasoning");
- expect(reasoningPart).toBeDefined();
- expect(reasoningPart).toMatchObject({
- type: "reasoning",
- text: "I thought about it",
- providerOptions: { anthropic: { signature: "S" } },
- });
- });
-
- it("tool-call input round-trip: ToolBatchEntry.arguments → ToolCallPart.input (not args)", async () => {
- // Pre-seed the agent with a prior assistant message containing a tool-batch chunk
- const agent = new Agent(makeConfig());
- agent.messages.push({
- role: "user",
- chunks: [{ type: "text", text: "run a tool" }],
- });
- agent.messages.push({
- role: "assistant",
- chunks: [
- {
- type: "tool-batch",
- calls: [
- {
- id: "call-1",
- name: "read_file",
- arguments: { path: "/foo/bar.txt" },
- result: "file contents",
- },
- ],
- },
- ],
- });
-
- // Next turn: just return a simple text response
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "Done" }, finishStop]),
- );
-
- for await (const _ of agent.run("follow-up")) {
- // consume
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
-
- // The assistant message should contain a tool-call part with `input` (not `args`)
- const assistantMsg = messages.find((m) => m.role === "assistant");
- expect(assistantMsg).toBeDefined();
- const content = assistantMsg?.content as Array<Record<string, unknown>>;
- const toolCallPart = content.find((p) => p.type === "tool-call");
- expect(toolCallPart).toBeDefined();
- expect(toolCallPart).toMatchObject({
- type: "tool-call",
- toolCallId: "call-1",
- toolName: "read_file",
- input: { path: "/foo/bar.txt" }, // v6: input not args
- });
- // Explicitly assert `args` is NOT present
- expect(toolCallPart).not.toHaveProperty("args");
- });
-
- it("tool-result output round-trip: result string → { type: 'text', value } ToolResultOutput", async () => {
- // Pre-seed the agent with a prior assistant message containing a tool-batch chunk
- const agent = new Agent(makeConfig());
- agent.messages.push({
- role: "user",
- chunks: [{ type: "text", text: "run a tool" }],
- });
- agent.messages.push({
- role: "assistant",
- chunks: [
- {
- type: "tool-batch",
- calls: [
- {
- id: "call-2",
- name: "read_file",
- arguments: { path: "/foo/baz.txt" },
- result: "the file content here",
- },
- ],
- },
- ],
- });
-
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "Done" }, finishStop]),
- );
-
- for await (const _ of agent.run("follow-up")) {
- // consume
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
-
- // The tool message should contain a tool-result part with `output` (ToolResultOutput)
- const toolMsg = messages.find((m) => m.role === "tool");
- expect(toolMsg).toBeDefined();
- const toolContent = toolMsg?.content as Array<Record<string, unknown>>;
- expect(toolContent[0]).toMatchObject({
- type: "tool-result",
- toolCallId: "call-2",
- toolName: "read_file",
- output: { type: "text", value: "the file content here" },
- });
- // Explicitly assert `result` (v4 raw string) is NOT present
- expect(toolContent[0]).not.toHaveProperty("result");
- });
-
- it("per-step segmentation: a [tool-batch, text] turn becomes [assistant(tool-call), tool(result), assistant(text)]", async () => {
- // `toModelMessages` segments a turn at each tool-batch boundary, so the
- // tool-batch (step 0) and the trailing text (step 1) land in SEPARATE
- // assistant messages — never a single invalid [tool_use, text] block.
- // This is the cache-stability fix and is applied for every provider.
- const agent = new Agent(makeConfig({ provider: "opencode-anthropic" }));
- agent.messages.push({
- role: "user",
- chunks: [{ type: "text", text: "run a tool and explain" }],
- });
- agent.messages.push({
- role: "assistant",
- chunks: [
- // Note: tool-batch appears BEFORE text in chunks — this is the
- // problematic ordering that Anthropic rejects
- {
- type: "tool-batch",
- calls: [
- {
- id: "call-3",
- name: "read_file",
- arguments: { path: "/tmp/x.txt" },
- result: "x contents",
- },
- ],
- },
- { type: "text", text: "Here is my explanation." },
- ],
- });
-
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- for await (const _ of agent.run("follow-up")) {
- // consume
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
-
- // No assistant message may mix tool-call and non-tool-call parts (the
- // invalid shape Anthropic rejects); segmentation guarantees this.
- const assistantMsgs = messages.filter((m) => m.role === "assistant");
- for (const m of assistantMsgs) {
- const c = m.content as Array<Record<string, unknown>>;
- if (!Array.isArray(c)) continue;
- const hasToolCall = c.some((p) => p.type === "tool-call");
- const hasNonToolCall = c.some((p) => p.type !== "tool-call");
- expect(hasToolCall && hasNonToolCall).toBe(false);
- }
-
- // The seeded turn yields a tool-call assistant message immediately
- // followed by its tool-result message (valid tool_use → tool_result).
- const toolOnlyIdx = messages.findIndex((m) => {
- const c = m.content as Array<Record<string, unknown>>;
- return m.role === "assistant" && Array.isArray(c) && c.some((p) => p.type === "tool-call");
- });
- expect(toolOnlyIdx).toBeGreaterThanOrEqual(0);
- expect(messages[toolOnlyIdx + 1]?.role).toBe("tool");
- });
-
- it("per-step segmentation also applies to the openai-compatible provider", async () => {
- // Segmentation is provider-agnostic: a [tool-batch, text] turn is split
- // into separate assistant messages for openai-compatible too, with the
- // tool result in its own tool message (the standard OpenAI shape).
- const agent = new Agent(makeConfig());
- agent.messages.push({
- role: "user",
- chunks: [{ type: "text", text: "run a tool and explain" }],
- });
- agent.messages.push({
- role: "assistant",
- chunks: [
- {
- type: "tool-batch",
- calls: [
- {
- id: "call-4",
- name: "read_file",
- arguments: { path: "/tmp/y.txt" },
- result: "y contents",
- },
- ],
- },
- { type: "text", text: "Here is my explanation." },
- ],
- });
-
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- for await (const _ of agent.run("follow-up")) {
- // consume
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
-
- // The seeded [tool-batch, text] turn is segmented: a tool-call-only
- // assistant message, its tool message, and a separate text assistant
- // message (the new turn's "ok" reply adds one more). No assistant
- // message mixes tool-call and non-tool-call parts.
- const assistantMsgs = messages.filter((m) => m.role === "assistant");
- for (const m of assistantMsgs) {
- const c = m.content as Array<Record<string, unknown>>;
- if (!Array.isArray(c)) continue;
- expect(c.some((p) => p.type === "tool-call") && c.some((p) => p.type !== "tool-call")).toBe(
- false,
- );
- }
- expect(messages.some((m) => m.role === "tool")).toBe(true);
- const toolCallMsg = assistantMsgs.find((m) => {
- const c = m.content as Array<Record<string, unknown>>;
- return Array.isArray(c) && c.some((p) => p.type === "tool-call");
- });
- expect(toolCallMsg).toBeDefined();
- });
-
- it("empty-text-part filter (Anthropic): empty text chunk is not sent", async () => {
- // Pre-seed an assistant message where a text chunk has empty text.
- const agent = new Agent(makeConfig({ provider: "opencode-anthropic" }));
- agent.messages.push({
- role: "user",
- chunks: [{ type: "text", text: "hello" }],
- });
- agent.messages.push({
- role: "assistant",
- chunks: [
- { type: "text", text: "" }, // empty text — should be filtered out
- { type: "text", text: "non-empty response" },
- ],
- });
-
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- for await (const _ of agent.run("follow-up")) {
- // consume
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
-
- const assistantMsg = messages.find((m) => m.role === "assistant");
- expect(assistantMsg).toBeDefined();
- const content = assistantMsg?.content as Array<Record<string, unknown>>;
-
- // Empty text part should have been filtered out
- const emptyTextParts = content.filter((p) => p.type === "text" && p.text === "");
- expect(emptyTextParts).toHaveLength(0);
-
- // The non-empty text part should still be there
- const nonEmptyTextParts = content.filter((p) => p.type === "text" && p.text !== "");
- expect(nonEmptyTextParts).toHaveLength(1);
- expect(nonEmptyTextParts[0]).toMatchObject({ text: "non-empty response" });
- });
-
- it("empty-reasoning-part filter (Anthropic): empty reasoning chunk is not sent", async () => {
- // Anthropic's adaptive thinking mode occasionally produces a signed-
- // but-empty thinking block. We persist it (for signature round-trip
- // fidelity) but strip the empty `reasoning` part before sending it
- // back, or Anthropic rejects with "thinking block must have content".
- const agent = new Agent(makeConfig({ provider: "opencode-anthropic" }));
- agent.messages.push({ role: "user", chunks: [{ type: "text", text: "hi" }] });
- agent.messages.push({
- role: "assistant",
- chunks: [
- // Signed-but-empty thinking block
- { type: "thinking", text: "", metadata: { anthropic: { signature: "sig-empty" } } },
- { type: "text", text: "answer" },
- ],
- });
-
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- for await (const _ of agent.run("follow-up")) {
- /* consume */
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
- const assistantMsg = messages.find((m) => m.role === "assistant");
- const content = assistantMsg?.content as Array<Record<string, unknown>>;
-
- // Empty reasoning part must have been filtered out by the
- // Anthropic structural normalisation pass.
- const emptyReasoning = content.filter((p) => p.type === "reasoning" && p.text === "");
- expect(emptyReasoning).toHaveLength(0);
-
- // The text part should still be there
- expect(content.some((p) => p.type === "text" && p.text === "answer")).toBe(true);
- });
-
- it("toolCallId scrubbing (Anthropic): non-[a-zA-Z0-9_-] chars in tool IDs are sanitised", async () => {
- // Anthropic rejects toolCallId outside [a-zA-Z0-9_-]. Our internal
- // crypto.randomUUID IDs are safe, but defensively scrub for any
- // upstream-assigned IDs (subagent retrieval, provider-executed
- // tools, MCP, etc.). Mirrors opencode transform.ts:96-122.
- const agent = new Agent(makeConfig({ provider: "opencode-anthropic" }));
- agent.messages.push({ role: "user", chunks: [{ type: "text", text: "do the thing" }] });
- agent.messages.push({
- role: "assistant",
- chunks: [
- {
- type: "tool-batch",
- calls: [
- {
- id: "call.with/dots:and:slashes", // invalid chars
- name: "fake_tool",
- arguments: { x: 1 },
- result: "ok",
- isError: false,
- },
- ],
- },
- ],
- });
-
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- for await (const _ of agent.run("follow-up")) {
- /* consume */
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
-
- // Assistant tool-call part must have scrubbed ID
- const assistantMsg = messages.find((m) => m.role === "assistant");
- const assistantContent = assistantMsg?.content as Array<Record<string, unknown>>;
- const toolCallPart = assistantContent.find((p) => p.type === "tool-call");
- expect(toolCallPart).toBeDefined();
- expect(toolCallPart?.toolCallId).toBe("call_with_dots_and_slashes");
-
- // Matching tool-result message must use the SAME scrubbed ID
- // so Anthropic can pair them.
- const toolMsg = messages.find((m) => m.role === "tool");
- const toolContent = toolMsg?.content as Array<Record<string, unknown>>;
- expect(toolContent?.[0]?.toolCallId).toBe("call_with_dots_and_slashes");
- });
-
- it("reasoning metadata captured from stream is round-tripped on the next turn", async () => {
- // End-to-end integrity for the providerMetadata round-trip — the
- // bug that prompted the entire migration. Stream a turn that
- // emits reasoning-delta + reasoning-end with metadata, then run
- // ANOTHER turn and verify the metadata reaches the model via
- // ReasoningPart.providerOptions.
- const agent = new Agent(makeConfig({ provider: "opencode-anthropic" }));
- const sig = { anthropic: { signature: "round-trip-sig-1" } };
-
- // Turn 1: model emits reasoning + signed reasoning-end
- vi.mocked(streamText).mockReturnValueOnce(
- makeMockStreamResult([
- { type: "reasoning-delta", id: "r0", text: "let me think" },
- { type: "reasoning-end", id: "r0", providerMetadata: sig },
- { type: "text-delta", id: "t0", text: "answer" },
- finishStop,
- ]),
- );
-
- for await (const _ of agent.run("first question")) {
- /* consume */
- }
-
- // After turn 1, the persisted chunks should include a ThinkingChunk
- // with the captured metadata. The agent's messages array IS the
- // canonical persisted shape (the DB just JSON-stringifies it).
- const turn1Assistant = agent.messages.find(
- (m, i) => m.role === "assistant" && i === agent.messages.length - 1,
- );
- expect(turn1Assistant).toBeDefined();
- const thinkingChunk = turn1Assistant?.chunks.find((c) => c.type === "thinking");
- expect(thinkingChunk).toBeDefined();
- expect(thinkingChunk).toMatchObject({ text: "let me think", metadata: sig });
-
- // Turn 2: drive another turn, capture what streamText receives.
- vi.mocked(streamText).mockReturnValueOnce(
- makeMockStreamResult([
- { type: "text-delta", id: "t1", text: "follow-up answer" },
- finishStop,
- ]),
- );
- for await (const _ of agent.run("second question")) {
- /* consume */
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
- const turn2Assistant = messages
- .filter((m) => m.role === "assistant")
- .find((m) => Array.isArray(m.content));
- const turn2Content = turn2Assistant?.content as Array<Record<string, unknown>>;
- const reasoningPart = turn2Content.find((p) => p.type === "reasoning");
- expect(reasoningPart).toBeDefined();
- expect(reasoningPart).toMatchObject({
- type: "reasoning",
- text: "let me think",
- providerOptions: sig,
- });
- });
-
- it("tool-error stream event yields a synthetic tool-result + error chunk and continues the turn", async () => {
- // Provider-executed tools (Anthropic server tools) bypass our
- // manual executor and surface as a `tool-error` stream event.
- // We must:
- // 1. Synthesize a tool-result with isError=true so the chunks
- // reflect that the tool ran and failed — this keeps the
- // tool-call/tool-result pairing complete and avoids the AI SDK
- // throwing MissingToolResultsError on the next round-trip.
- // 2. Emit an error chunk so the UI shows the failure.
- // 3. NOT transition to "error" status — the step breaks out of the
- // stream loop and the turn ends normally (here, with no further
- // tool calls pending, the agent completes to idle).
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([
- {
- type: "tool-error",
- toolCallId: "tc_server",
- toolName: "server_tool",
- error: new Error("upstream tool failure"),
- },
- finishStop,
- ]),
- );
-
- const agent = new Agent(makeConfig());
- const events: AgentEvent[] = [];
- for await (const event of agent.run("trigger")) {
- events.push(event);
- }
-
- // Synthetic tool-result with the upstream error
- const trEvent = events.find((e) => e.type === "tool-result");
- expect(trEvent).toBeDefined();
- expect(trEvent).toMatchObject({
- type: "tool-result",
- toolResult: { toolCallId: "tc_server", isError: true },
- });
-
- // Error chunk for visibility
- const errEvent = events.find((e) => e.type === "error");
- expect(errEvent).toBeDefined();
- const errMsg = errEvent && "error" in errEvent ? errEvent.error : "";
- expect(typeof errMsg).toBe("string");
- expect((errMsg as string).includes("upstream tool failure")).toBe(true);
-
- // Status does NOT transition to error — the turn completes to idle.
- const lastStatus = events.filter((e) => e.type === "status").at(-1);
- expect(lastStatus).toMatchObject({ type: "status", status: "idle" });
-
- // The turn produced a `done` event (it did not abort).
- expect(events.some((e) => e.type === "done")).toBe(true);
- });
-
- it("tool-error leaves sibling tool calls to be resolved by the executor (not orphaned)", async () => {
- // When one tool in a batch errors, its siblings — whose tool-call
- // events were already yielded — must still receive a result, otherwise
- // the tool-call IDs are orphaned in the chunks (no matching result)
- // and the next LLM round-trip throws MissingToolResultsError. The
- // tool-error handler breaks out of the stream loop WITHOUT executing
- // the unresolved siblings inline; the normal manual-executor pass then
- // runs them. Here `sibling_tool` is not a registered tool, so the
- // executor returns an "Unknown tool" error result — completing the
- // tool-call/tool-result pairing with `isError: true`.
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([
- {
- type: "tool-call",
- toolCallId: "tc_sibling",
- toolName: "sibling_tool",
- input: {},
- },
- {
- type: "tool-error",
- toolCallId: "tc_failed",
- toolName: "failed_tool",
- error: new Error("boom"),
- },
- finishStop,
- ]),
- );
-
- const agent = new Agent(makeConfig());
- const events: AgentEvent[] = [];
- for await (const event of agent.run("trigger")) {
- events.push(event);
- }
-
- const toolResults = events.filter((e) => e.type === "tool-result");
- // One for the failed tool, one for the sibling resolved by the executor.
- const siblingResult = toolResults.find(
- (e) => "toolResult" in e && e.toolResult.toolCallId === "tc_sibling",
- );
- expect(siblingResult).toBeDefined();
- expect(siblingResult).toMatchObject({
- type: "tool-result",
- toolResult: { toolCallId: "tc_sibling", isError: true },
- });
- const siblingMsg =
- siblingResult && "toolResult" in siblingResult ? siblingResult.toolResult.result : "";
- expect((siblingMsg as string).includes("sibling_tool")).toBe(true);
-
- // Status completes to idle (the turn continued, not aborted).
- const lastStatus = events.filter((e) => e.type === "status").at(-1);
- expect(lastStatus).toMatchObject({ type: "status", status: "idle" });
- });
-
- it("abort stream event surfaces as an error event and stops the turn", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([
- { type: "text-delta", id: "t0", text: "starting..." },
- { type: "abort", reason: "user cancelled" },
- ]),
- );
-
- const agent = new Agent(makeConfig());
- const events: AgentEvent[] = [];
- for await (const event of agent.run("hi")) {
- events.push(event);
- }
-
- const errEvent = events.find((e) => e.type === "error");
- expect(errEvent).toBeDefined();
- const errMsg = errEvent && "error" in errEvent ? errEvent.error : "";
- expect(
- (errMsg as string).toLowerCase().includes("aborted") ||
- (errMsg as string).includes("user cancelled"),
- ).toBe(true);
-
- const lastStatus = events.filter((e) => e.type === "status").at(-1);
- expect(lastStatus).toMatchObject({ type: "status", status: "error" });
- });
-
- it("openai-compatible reasoning round-trip: ThinkingChunk -> providerOptions.openaiCompatible.reasoning_content (DeepSeek scenario)", async () => {
- // Reproducer for the "reasoning_content must be passed back" error
- // from DeepSeek via OpenCode Go.
- //
- // applyOpenAICompatibleReasoningNormalisation strips the
- // `{ type: "reasoning", text }` parts and lifts the concatenated
- // text into `providerOptions.openaiCompatible.reasoning_content`.
- // The v6 SDK provider serializes the message-level
- // `providerOptions.openaiCompatible.*` into the wire `assistant`
- // message via its `metadata` spread (line 247 of the SDK dist).
- // This route emits `reasoning_content` regardless of empty/non-
- // empty text — which is what DeepSeek requires.
- const agent = new Agent(
- makeConfig({
- model: "deepseek-v4-pro",
- // no provider field → default openai-compatible path
- }),
- );
- agent.messages.push(
- { role: "user", chunks: [{ type: "text", text: "ping" }] },
- {
- role: "assistant",
- chunks: [
- { type: "thinking", text: "let me reason about this" },
- { type: "text", text: "ok done" },
- ],
- },
- );
-
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- for await (const _ of agent.run("follow-up")) {
- /* consume */
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{
- role: string;
- content: unknown;
- providerOptions?: { openaiCompatible?: { reasoning_content?: string } };
- }>;
- const assistantMsg = messages.find((m) => m.role === "assistant");
- if (!assistantMsg || !Array.isArray(assistantMsg.content)) {
- throw new Error("expected structured assistant content");
- }
- const content = assistantMsg.content as Array<Record<string, unknown>>;
-
- // Reasoning parts have been stripped from content (lifted into
- // providerOptions instead).
- expect(content.find((p) => p.type === "reasoning")).toBeUndefined();
-
- // reasoning_content is set on providerOptions.openaiCompatible.
- // This is what reaches DeepSeek and prevents the rejection.
- expect(assistantMsg.providerOptions?.openaiCompatible?.reasoning_content).toBe(
- "let me reason about this",
- );
-
- // The text part still survives in content.
- const textPart = content.find((p) => p.type === "text");
- expect(textPart).toMatchObject({ type: "text", text: "ok done" });
-
- // And critically, the message must NOT carry a providerMetadata
- // key (the v4-era misnamed key). v3 prompts use `providerOptions`.
- expect((assistantMsg as Record<string, unknown>).providerMetadata).toBeUndefined();
- });
-
- it("openai-compatible empty-reasoning edge case: forces reasoning_content='' so DeepSeek does not reject", async () => {
- // DeepSeek will reject the follow-up turn with "must be passed
- // back" if a prior assistant turn emitted reasoning AND the
- // follow-up doesn't include `reasoning_content` (even empty).
- // The v6 SDK's content-side path skips emission when reasoning
- // is empty (see `dist/index.mjs:245`); our normalisation routes
- // it via providerOptions instead, which fires unconditionally.
- const agent = new Agent(makeConfig({ model: "deepseek-v4-pro" }));
- agent.messages.push(
- { role: "user", chunks: [{ type: "text", text: "ping" }] },
- {
- role: "assistant",
- chunks: [
- // Empty thinking — captured but produced no actual text.
- { type: "thinking", text: "" },
- { type: "text", text: "answer" },
- ],
- },
- );
-
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- for await (const _ of agent.run("follow-up")) {
- /* consume */
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{
- role: string;
- content: unknown;
- providerOptions?: { openaiCompatible?: { reasoning_content?: string } };
- }>;
- const assistantMsg = messages.find((m) => m.role === "assistant");
- if (!assistantMsg) throw new Error("expected assistant message");
-
- // The empty-string reasoning_content is explicitly set on
- // providerOptions. (`""` is intentional and required —
- // `assistantMsg.providerOptions?.openaiCompatible?.reasoning_content`
- // must not be `undefined`.)
- const rc = assistantMsg.providerOptions?.openaiCompatible?.reasoning_content;
- expect(rc).toBeDefined();
- expect(rc).toBe("");
- });
-
- it("openai-compatible normalisation does NOT run for messages without any reasoning parts", async () => {
- // DeepSeek only requires `reasoning_content` AFTER a thinking
- // turn. For purely-text assistant messages, we should leave
- // providerOptions alone.
- const agent = new Agent(makeConfig({ model: "deepseek-v4-pro" }));
- agent.messages.push(
- { role: "user", chunks: [{ type: "text", text: "hi" }] },
- {
- role: "assistant",
- chunks: [{ type: "text", text: "hello back" }],
- },
- );
-
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- for await (const _ of agent.run("again")) {
- /* consume */
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{
- role: string;
- providerOptions?: { openaiCompatible?: { reasoning_content?: string } };
- }>;
- const assistantMsg = messages.find((m) => m.role === "assistant");
-
- // No reasoning chunks → no providerOptions injection. (May still
- // be undefined entirely if nothing else set it.)
- const rc = assistantMsg?.providerOptions?.openaiCompatible?.reasoning_content;
- expect(rc).toBeUndefined();
- });
-
- // ─── Prompt-caching: tool-result grouping & breakpoints (notes/claude-report.md) ──
-
- it("groups a turn's tool results into a SINGLE role:'tool' message (Root Cause 2)", async () => {
- // The agent batches three distinct read_file calls in one step. The
- // rebuilt ModelMessage[] must contain exactly ONE `role: "tool"` message
- // holding all three results (not three separate tool messages). Per-
- // result messages would strand the rolling cache breakpoints on the last
- // two adjacent tool results, wasting a breakpoint.
- const toolDef = {
- name: "read_file",
- description: "reads a file",
- parameters: z.object({ path: z.string() }),
- execute: async (args: Record<string, unknown>) => `contents of ${String(args.path)}`,
- };
- vi.mocked(streamText)
- .mockReturnValueOnce(
- makeMockStreamResult([
- { type: "tool-call", toolCallId: "b1", toolName: "read_file", input: { path: "a.txt" } },
- { type: "tool-call", toolCallId: "b2", toolName: "read_file", input: { path: "b.txt" } },
- { type: "tool-call", toolCallId: "b3", toolName: "read_file", input: { path: "c.txt" } },
- finishToolCalls,
- ]),
- )
- .mockReturnValueOnce(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "done" }, finishStop]),
- );
-
- const agent = new Agent(makeConfig({ provider: "opencode-anthropic", tools: [toolDef] }));
- for await (const _ of agent.run("read three files")) {
- /* consume */
- }
-
- // Inspect the step-1 request (sent after the batch executed) — its tail
- // is the assistant tool-calls + the grouped tool results.
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
-
- const toolMsgs = messages.filter((m) => m.role === "tool");
- expect(toolMsgs).toHaveLength(1);
- const toolContent = toolMsgs[0]?.content as Array<Record<string, unknown>>;
- expect(toolContent).toHaveLength(3);
- expect(toolContent.every((p) => p.type === "tool-result")).toBe(true);
- // IDs preserved per result.
- expect(toolContent.map((p) => p.toolCallId)).toEqual(["b1", "b2", "b3"]);
- });
-
- it("places cache breakpoints on [assistant, grouped-tool], not adjacent tool results (Root Cause 2)", async () => {
- // With grouping, the last two non-system messages of a mid-turn request
- // are [assistant(tool-calls), tool(all results)]. Both — plus the system
- // message — must carry an ephemeral cacheControl marker. The pre-fix bug
- // put both rolling breakpoints on two adjacent tool-result messages and
- // never marked the assistant turn.
- const toolDef = {
- name: "read_file",
- description: "reads a file",
- parameters: z.object({ path: z.string() }),
- execute: async (args: Record<string, unknown>) => `contents of ${String(args.path)}`,
- };
- vi.mocked(streamText)
- .mockReturnValueOnce(
- makeMockStreamResult([
- { type: "tool-call", toolCallId: "c1", toolName: "read_file", input: { path: "a.txt" } },
- { type: "tool-call", toolCallId: "c2", toolName: "read_file", input: { path: "b.txt" } },
- { type: "tool-call", toolCallId: "c3", toolName: "read_file", input: { path: "c.txt" } },
- finishToolCalls,
- ]),
- )
- .mockReturnValueOnce(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "done" }, finishStop]),
- );
-
- const agent = new Agent(makeConfig({ provider: "opencode-anthropic", tools: [toolDef] }));
- for await (const _ of agent.run("read three files")) {
- /* consume */
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{
- role: string;
- content: unknown;
- providerOptions?: { anthropic?: { cacheControl?: { type?: string } } };
- }>;
-
- const isCached = (m?: {
- providerOptions?: { anthropic?: { cacheControl?: { type?: string } } };
- }) => m?.providerOptions?.anthropic?.cacheControl?.type === "ephemeral";
-
- // Exactly one tool message — no adjacent tool-result breakpoints.
- expect(messages.filter((m) => m.role === "tool")).toHaveLength(1);
-
- const systemMsg = messages.find((m) => m.role === "system");
- const assistantMsg = messages.find((m) => m.role === "assistant");
- const toolMsg = messages.find((m) => m.role === "tool");
-
- expect(isCached(systemMsg)).toBe(true);
- expect(isCached(assistantMsg)).toBe(true);
- expect(isCached(toolMsg)).toBe(true);
- });
-
- it("does NOT attach cacheControl for the openai-compatible (non-Anthropic) path", async () => {
- // Sanity check: caching markers are Anthropic-only. The OpenAI-compatible
- // endpoints do automatic server-side prefix caching and reject explicit
- // cache_control, so no marker should be attached.
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "hi" }, finishStop]),
- );
- const agent = new Agent(makeConfig()); // default → openai-compatible
- for await (const _ of agent.run("hello")) {
- /* consume */
- }
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{
- role: string;
- providerOptions?: { anthropic?: { cacheControl?: unknown } };
- }>;
- for (const m of messages) {
- expect(m.providerOptions?.anthropic?.cacheControl).toBeUndefined();
- }
- });
-
- // ─── Tool-call dedup (notes/tool-runner-duplication-incident.md) ─────────────────
-
- it("deduplicates byte-identical tool calls within a single batch", async () => {
- // Claude can degenerate and emit the same tool call (same name + args)
- // many times in one batch. Each copy keeps its own id (and still gets its
- // own result), but the tool must execute only ONCE — re-running identical
- // idempotent reads wastes time/money and floods the context.
- let execCount = 0;
- const toolDef = {
- name: "read_file",
- description: "reads a file",
- parameters: z.object({ path: z.string() }),
- execute: async (args: Record<string, unknown>) => {
- execCount++;
- return `contents of ${String(args.path)}`;
- },
- };
- vi.mocked(streamText)
- .mockReturnValueOnce(
- makeMockStreamResult([
- {
- type: "tool-call",
- toolCallId: "d1",
- toolName: "read_file",
- input: { path: "package.json" },
- },
- {
- type: "tool-call",
- toolCallId: "d2",
- toolName: "read_file",
- input: { path: "package.json" },
- },
- {
- type: "tool-call",
- toolCallId: "d3",
- toolName: "read_file",
- input: { path: "package.json" },
- },
- finishToolCalls,
- ]),
- )
- .mockReturnValueOnce(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "done" }, finishStop]),
- );
-
- const agent = new Agent(makeConfig({ tools: [toolDef] }));
- const events: AgentEvent[] = [];
- for await (const e of agent.run("read it thrice")) {
- events.push(e);
- }
-
- // Executed exactly once despite three identical calls.
- expect(execCount).toBe(1);
-
- // Every call id still received its own result, all with identical content.
- const results = events.filter(
- (e): e is Extract<AgentEvent, { type: "tool-result" }> => e.type === "tool-result",
- );
- expect(results).toHaveLength(3);
- expect(results.map((e) => e.toolResult.toolCallId).sort()).toEqual(["d1", "d2", "d3"]);
- for (const r of results) {
- expect(r.toolResult.result).toBe("contents of package.json");
- }
- });
-
- it("does NOT deduplicate tool calls with differing arguments", async () => {
- // Dedup is keyed on name + serialized arguments. Distinct args must each
- // execute — only byte-identical calls collapse.
- let execCount = 0;
- const toolDef = {
- name: "read_file",
- description: "reads a file",
- parameters: z.object({ path: z.string() }),
- execute: async (args: Record<string, unknown>) => {
- execCount++;
- return `contents of ${String(args.path)}`;
- },
- };
- vi.mocked(streamText)
- .mockReturnValueOnce(
- makeMockStreamResult([
- { type: "tool-call", toolCallId: "e1", toolName: "read_file", input: { path: "a.txt" } },
- { type: "tool-call", toolCallId: "e2", toolName: "read_file", input: { path: "b.txt" } },
- { type: "tool-call", toolCallId: "e3", toolName: "read_file", input: { path: "a.txt" } },
- finishToolCalls,
- ]),
- )
- .mockReturnValueOnce(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "done" }, finishStop]),
- );
-
- const agent = new Agent(makeConfig({ tools: [toolDef] }));
- for await (const _ of agent.run("read a, b, a")) {
- /* consume */
- }
-
- // a.txt + b.txt are distinct → two executions; the repeated a.txt reuses
- // the first result.
- expect(execCount).toBe(2);
- });
-
- // ─── Cache stability: per-step wire prefix is immutable ─────────────────────
-
- it("keeps earlier steps' wire messages byte-identical across requests (cache prefix is stable)", async () => {
- // A 3-step tool turn. The messages for steps 0 and 1 must serialize
- // identically in the step-2 request and the step-3 request — that
- // byte-stability is what lets Anthropic's rolling prompt cache extend
- // instead of re-writing the whole prefix every step (notes/cache-miss-report.md).
- // Uses the openai-compatible provider so no cacheControl markers (which
- // intentionally move each step) obscure the content comparison.
- let n = 0;
- // mock.calls accumulates across tests in this file — reset so our
- // `calls.length` assertions count only this run's requests.
- vi.mocked(streamText).mockClear();
- const toolDef = {
- name: "read_file",
- description: "reads a file",
- parameters: z.object({ path: z.string() }),
- execute: async (args: Record<string, unknown>) => `contents of ${String(args.path)}`,
- };
- const toolStep = (id: string, path: string) =>
- makeMockStreamResult([
- { type: "reasoning-delta", id: `r${id}`, text: `thinking ${id}` },
- { type: "text-delta", id: `t${id}`, text: `step ${id}` },
- { type: "tool-call", toolCallId: id, toolName: "read_file", input: { path } },
- finishToolCalls,
- ]);
- vi.mocked(streamText).mockImplementation(() => {
- n++;
- if (n === 1) return toolStep("s0", "a.txt");
- if (n === 2) return toolStep("s1", "b.txt");
- if (n === 3) return toolStep("s2", "c.txt");
- return makeMockStreamResult([{ type: "text-delta", id: "tf", text: "done" }, finishStop]);
- });
-
- const agent = new Agent(makeConfig({ tools: [toolDef] }));
- for await (const _ of agent.run("go")) {
- /* consume */
- }
-
- // 4 streamText calls (steps 0..3). Compare the step-2 request (call idx 2)
- // and step-3 request (call idx 3).
- const calls = vi.mocked(streamText).mock.calls;
- expect(calls.length).toBe(4);
- const req2 = calls[2]?.[0]?.messages as unknown[];
- const req3 = calls[3]?.[0]?.messages as unknown[];
-
- // Step-2 request = [system, user, a(s0), tool(s0), a(s1), tool(s1)] (6).
- // Step-3 request appends a(s2), tool(s2). The shared 6-message prefix
- // must be byte-identical.
- expect(req2).toHaveLength(6);
- expect(req3).toHaveLength(8);
- expect(JSON.stringify(req3.slice(0, 6))).toBe(JSON.stringify(req2));
-
- // And each step really is its own [assistant, tool] pair (not one merged
- // assistant message with all tool calls bunched together).
- const roles = (req3 as Array<{ role: string }>).map((m) => m.role);
- expect(roles).toEqual([
- "system",
- "user",
- "assistant",
- "tool",
- "assistant",
- "tool",
- "assistant",
- "tool",
- ]);
- });
-
- // ─── Usage / cache-rate telemetry ──────────────────────────────────────────
-
- it("emits a usage event from the finish-step part with the cache read/write split", async () => {
- // The per-step `usage` (with Anthropic's cache read/write split in
- // `inputTokenDetails`) rides on the `finish-step` part — NOT the terminal
- // `finish` part, which only carries the aggregate `totalUsage`. The agent
- // re-emits it as a `usage` AgentEvent that powers the Cache Rate view.
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([
- { type: "text-delta", id: "t0", text: "hi" },
- {
- type: "finish-step",
- finishReason: "stop",
- rawFinishReason: "stop",
- usage: {
- inputTokens: 1000,
- outputTokens: 50,
- inputTokenDetails: {
- noCacheTokens: 200,
- cacheReadTokens: 750,
- cacheWriteTokens: 50,
- },
- },
- },
- finishStop,
- ]),
- );
-
- const agent = new Agent(makeConfig());
- const events: AgentEvent[] = [];
- for await (const e of agent.run("hi")) {
- events.push(e);
- }
-
- const usageEvents = events.filter(
- (e): e is Extract<AgentEvent, { type: "usage" }> => e.type === "usage",
- );
- // Exactly one usage event (from finish-step) — the terminal `finish`
- // part must NOT double-count.
- expect(usageEvents).toHaveLength(1);
- expect(usageEvents[0]?.usage).toEqual({
- inputTokens: 1000,
- outputTokens: 50,
- cacheReadTokens: 750,
- cacheWriteTokens: 50,
- });
- });
-
- it("does NOT emit a usage event when no finish-step usage is present", async () => {
- // `finishStop` (type `finish`, aggregate `totalUsage` only) must not
- // trigger a usage event — and with no `finish-step` part there is no
- // per-step usage to emit.
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "hi" }, finishStop]),
- );
-
- const agent = new Agent(makeConfig());
- const events: AgentEvent[] = [];
- for await (const e of agent.run("hi")) {
- events.push(e);
- }
-
- expect(events.some((e) => e.type === "usage")).toBe(false);
- });
-});
-
-describe("anthropicThinkingProviderOptions — adaptive-thinking model detection", () => {
- // Pure function: no provider construction, no streamText, no network I/O.
- // Mirrors opencode's transform.ts detection — Opus 4.7+ AND Opus/Sonnet 4.6
- // are adaptive; only Opus 4.7+ needs display:"summarized" to surface thinking.
-
- it("Opus 4.8 → adaptive + display:summarized (the reported bug)", () => {
- expect(anthropicThinkingProviderOptions("claude-opus-4-8", "max")).toEqual({
- thinking: { type: "adaptive", display: "summarized" },
- effort: "max",
- });
- });
-
- it("Opus 4.7 → adaptive + display:summarized (dash and dot id forms)", () => {
- const expected = { thinking: { type: "adaptive", display: "summarized" }, effort: "high" };
- expect(anthropicThinkingProviderOptions("claude-opus-4-7", "high")).toEqual(expected);
- expect(anthropicThinkingProviderOptions("claude-opus-4.7", "high")).toEqual(expected);
- });
-
- it("Sonnet 4.6 → adaptive WITHOUT display (dash and dot id forms)", () => {
- const expected = { thinking: { type: "adaptive" }, effort: "medium" };
- expect(anthropicThinkingProviderOptions("claude-sonnet-4-6", "medium")).toEqual(expected);
- expect(anthropicThinkingProviderOptions("claude-sonnet-4.6", "medium")).toEqual(expected);
- });
-
- it("Opus 4.6 → adaptive WITHOUT display", () => {
- expect(anthropicThinkingProviderOptions("claude-opus-4-6", "high")).toEqual({
- thinking: { type: "adaptive" },
- effort: "high",
- });
- });
-
- it("older Claude (Opus 4.5, dated Sonnet) → classic enabled thinking", () => {
- expect(anthropicThinkingProviderOptions("claude-opus-4-5", "max")).toEqual({
- thinking: { type: "enabled", budgetTokens: 31999 },
- });
- expect(anthropicThinkingProviderOptions("claude-sonnet-4-20250514", "high")).toEqual({
- thinking: { type: "enabled", budgetTokens: 16000 },
- });
- });
-
- it("uses a version parse, not a hardcoded string (future Opus 4.9 is adaptive)", () => {
- expect(anthropicThinkingProviderOptions("claude-opus-4-9", "high")).toEqual({
- thinking: { type: "adaptive", display: "summarized" },
- effort: "high",
- });
- });
-
- it("maps reasoning effort → budgetTokens for enabled (non-adaptive) models", () => {
- const budget = (e: "low" | "medium" | "high" | "xhigh" | "max") => {
- const opts = anthropicThinkingProviderOptions("claude-3-7-sonnet", e) as {
- thinking: { type: "enabled"; budgetTokens: number };
- };
- return opts.thinking.budgetTokens;
- };
- expect(budget("low")).toBe(2000);
- expect(budget("medium")).toBe(5000);
- expect(budget("high")).toBe(16000);
- expect(budget("xhigh")).toBe(24000);
- expect(budget("max")).toBe(31999);
- });
-
- it("xhigh budget sits strictly between high and max (ordering invariant)", () => {
- const budget = (e: "high" | "xhigh" | "max") => {
- const opts = anthropicThinkingProviderOptions("claude-3-7-sonnet", e) as {
- thinking: { type: "enabled"; budgetTokens: number };
- };
- return opts.thinking.budgetTokens;
- };
- expect(budget("high")).toBeLessThan(budget("xhigh"));
- expect(budget("xhigh")).toBeLessThan(budget("max"));
- });
-
- it("forwards xhigh verbatim as the adaptive effort sibling (Opus 4.7+)", () => {
- expect(anthropicThinkingProviderOptions("claude-opus-4-8", "xhigh")).toEqual({
- thinking: { type: "adaptive", display: "summarized" },
- effort: "xhigh",
- });
- });
-
- describe("multimodal user content", () => {
- it("emits ordered text + image parts to the model when content is provided", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- const agent = new Agent(makeConfig());
- for await (const _ of agent.run("here is image A: [image]", {
- content: [
- { type: "text", text: "here is image A: " },
- { type: "attachment", mediaType: "image/png", data: "QQ==" },
- ],
- })) {
- // consume
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
- const userMsg = messages.find((m) => m.role === "user");
- expect(userMsg).toBeDefined();
- // Multimodal turn → content is an ordered parts array, not a string.
- expect(Array.isArray(userMsg?.content)).toBe(true);
- const parts = userMsg?.content as Array<Record<string, unknown>>;
- expect(parts[0]).toMatchObject({ type: "text", text: "here is image A: " });
- expect(parts[1]).toMatchObject({ type: "image", mediaType: "image/png" });
- expect(String(parts[1]?.image)).toBe("data:image/png;base64,QQ==");
- });
-
- it("emits a FilePart for a PDF attachment with its filename", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- const agent = new Agent(makeConfig());
- for await (const _ of agent.run("see [pdf]", {
- content: [
- { type: "text", text: "see " },
- { type: "attachment", mediaType: "application/pdf", data: "QQ==", name: "doc.pdf" },
- ],
- })) {
- // consume
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
- const userMsg = messages.find((m) => m.role === "user");
- const parts = userMsg?.content as Array<Record<string, unknown>>;
- const filePart = parts.find((p) => p.type === "file");
- expect(filePart).toMatchObject({
- type: "file",
- mediaType: "application/pdf",
- filename: "doc.pdf",
- });
- expect(String(filePart?.data)).toBe("data:application/pdf;base64,QQ==");
- });
-
- it("persists the user turn as text only (no content) for history", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- const agent = new Agent(makeConfig());
- for await (const _ of agent.run("look: [image]", {
- content: [
- { type: "text", text: "look: " },
- { type: "attachment", mediaType: "image/png", data: "QQ==" },
- ],
- })) {
- // consume
- }
-
- // The in-memory user message keeps the text chunk for the render/persist
- // path; the ephemeral `content` rides alongside it but isn't a chunk.
- const userMsg = agent.messages.find((m) => m.role === "user");
- expect(userMsg?.chunks).toEqual([{ type: "text", text: "look: [image]" }]);
- });
-
- it("falls back to a plain string when content has no attachment", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "ok" }, finishStop]),
- );
-
- const agent = new Agent(makeConfig());
- for await (const _ of agent.run("plain text", {
- content: [{ type: "text", text: "plain text" }],
- })) {
- // consume
- }
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
- const userMsg = messages.find((m) => m.role === "user");
- // No attachment → plain string content (byte-identical to text-only path).
- expect(typeof userMsg?.content).toBe("string");
- expect(userMsg?.content).toBe("plain text");
- });
- });
-
- describe("warmCache (prompt-cache warming replay)", () => {
- function makeWarmStream(usage: {
- inputTokens: number;
- cacheReadTokens: number;
- cacheWriteTokens: number;
- }) {
- return makeMockStreamResult([
- { type: "text-delta", id: "t0", text: "." },
- {
- type: "finish-step",
- finishReason: "stop",
- rawFinishReason: "stop",
- usage: {
- inputTokens: usage.inputTokens,
- outputTokens: 1,
- inputTokenDetails: {
- noCacheTokens: usage.inputTokens - usage.cacheReadTokens - usage.cacheWriteTokens,
- cacheReadTokens: usage.cacheReadTokens,
- cacheWriteTokens: usage.cacheWriteTokens,
- },
- },
- },
- finishStop,
- ]);
- }
-
- const history = [
- { role: "user" as const, chunks: [{ type: "text" as const, text: "hello" }] },
- { role: "assistant" as const, chunks: [{ type: "text" as const, text: "hi there" }] },
- ];
-
- it("returns the request usage (cache read/write split) without throwing", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeWarmStream({ inputTokens: 1000, cacheReadTokens: 950, cacheWriteTokens: 0 }),
- );
- const agent = new Agent(makeConfig({ provider: "anthropic" }));
- const usage = await agent.warmCache(history);
- expect(usage).toEqual({
- inputTokens: 1000,
- outputTokens: 1,
- cacheReadTokens: 950,
- cacheWriteTokens: 0,
- });
- });
-
- it("appends a single trivial throwaway user turn at the END of the history", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeWarmStream({ inputTokens: 10, cacheReadTokens: 5, cacheWriteTokens: 0 }),
- );
- const agent = new Agent(makeConfig({ provider: "anthropic" }));
- await agent.warmCache(history);
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- const messages = callArgs?.messages as Array<{ role: string; content: unknown }>;
- // system + 2 history messages + 1 throwaway user turn.
- expect(messages[0]?.role).toBe("system");
- const last = messages.at(-1);
- expect(last?.role).toBe("user");
- // The throwaway turn's text must be the trivial probe.
- const lastText = JSON.stringify(last?.content);
- expect(lastText).toContain("reply with just a .");
- // Exactly one extra user turn beyond the genuine history's single user msg.
- const userMsgs = messages.filter((m) => m.role === "user");
- expect(userMsgs).toHaveLength(2);
- });
-
- it("sends Anthropic cache_control breakpoints with the SAME toolChoice/thinking as a real turn", async () => {
- // Anthropic keys the MESSAGE cache on `tool_choice` AND the extended-
- // thinking parameters. If warming sent a different value than a real
- // turn, it would warm a DIFFERENT message-cache bucket and the user's
- // next real message would still miss. So warming MUST mirror run():
- // toolChoice "auto" + the thinking providerOptions for the effort.
- vi.mocked(streamText).mockReturnValue(
- makeWarmStream({ inputTokens: 10, cacheReadTokens: 5, cacheWriteTokens: 0 }),
- );
- const agent = new Agent(makeConfig({ provider: "anthropic" }));
- await agent.warmCache(history);
-
- const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- expect(callArgs?.toolChoice).toBe("auto");
- // Thinking providerOptions present (effort defaults to "max").
- expect(callArgs?.providerOptions?.anthropic).toBeDefined();
- const messages = callArgs?.messages as Array<{
- role: string;
- providerOptions?: { anthropic?: { cacheControl?: unknown } };
- }>;
- const hasBreakpoint = messages.some(
- (m) => m.providerOptions?.anthropic?.cacheControl !== undefined,
- );
- expect(hasBreakpoint).toBe(true);
- });
-
- it("warming and a real turn send IDENTICAL cache-affecting params (same bucket)", async () => {
- // The core invariant of the whole feature: warmCache() and run() must
- // produce the same toolChoice + thinking providerOptions + maxOutputTokens
- // so the warming replay refreshes the EXACT cache the next real message
- // reads. Drive both and compare the cache-key inputs streamText receives.
- const cfg = makeConfig({ provider: "anthropic" });
-
- // 1) Real turn for the same history + the probe text as the user msg.
- const realAgent = new Agent(cfg);
- realAgent.messages.push(...history.map((m) => ({ ...m })));
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "text-delta", id: "t0", text: "." }, finishStop]),
- );
- for await (const _ of realAgent.run("reply with just a .")) {
- // consume
- }
- const realArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
-
- // 2) Warming replay for the same history.
- const warmAgent = new Agent(cfg);
- vi.mocked(streamText).mockReturnValue(
- makeWarmStream({ inputTokens: 10, cacheReadTokens: 5, cacheWriteTokens: 0 }),
- );
- await warmAgent.warmCache(history);
- const warmArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
-
- // The cache-affecting parameters must be byte-identical.
- expect(warmArgs?.toolChoice).toEqual(realArgs?.toolChoice);
- expect(warmArgs?.maxOutputTokens).toEqual(realArgs?.maxOutputTokens);
- expect(warmArgs?.providerOptions).toEqual(realArgs?.providerOptions);
- });
-
- it("does NOT mutate the agent's own message history", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeWarmStream({ inputTokens: 10, cacheReadTokens: 5, cacheWriteTokens: 0 }),
- );
- const agent = new Agent(makeConfig({ provider: "anthropic" }));
- expect(agent.messages).toHaveLength(0);
- await agent.warmCache(history);
- // warmCache takes history as an argument and never touches `this.messages`.
- expect(agent.messages).toHaveLength(0);
- // And it must not have flipped the agent into a running state.
- expect(agent.status).toBe("idle");
- });
-
- it("throws a formatted error when the stream errors", async () => {
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult([{ type: "error", error: new Error("boom") }]),
- );
- const agent = new Agent(makeConfig({ provider: "anthropic" }));
- await expect(agent.warmCache(history)).rejects.toThrow(/boom/);
- });
- });
-});
diff --git a/packages/core/tests/agents/loader.test.ts b/packages/core/tests/agents/loader.test.ts
deleted file mode 100644
index a223a4f..0000000
--- a/packages/core/tests/agents/loader.test.ts
+++ /dev/null
@@ -1,233 +0,0 @@
-import * as fs from "node:fs";
-import * as os from "node:os";
-import * as path from "node:path";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import {
- expandAgentToolNames,
- getAgentDirPaths,
- loadAgent,
- saveAgent,
-} from "../../src/agents/loader.js";
-
-describe("expandAgentToolNames", () => {
- it("expands 'read' into the granular read tools", () => {
- const out = expandAgentToolNames(["read"]);
- expect(out).toContain("read_file");
- expect(out).toContain("read_file_slice");
- expect(out).toContain("list_files");
- });
-
- it("expands 'edit' into write_file", () => {
- const out = expandAgentToolNames(["edit"]);
- expect(out).toContain("write_file");
- });
-
- it("expands 'bash' into run_shell", () => {
- const out = expandAgentToolNames(["bash"]);
- expect(out).toContain("run_shell");
- });
-
- it("passes through the tab tools as independent names (no tab_comm group)", () => {
- const out = expandAgentToolNames(["send_to_tab", "read_tab"]);
- expect(out).toContain("send_to_tab");
- expect(out).toContain("read_tab");
- // Granting only one must not pull in the other.
- const onlySend = expandAgentToolNames(["send_to_tab"]);
- expect(onlySend).toContain("send_to_tab");
- expect(onlySend).not.toContain("read_tab");
- });
-
- it("passes through non-group tool names unchanged", () => {
- const out = expandAgentToolNames([
- "summon",
- "retrieve",
- "web_search",
- "youtube_transcribe",
- "search_code",
- "send_to_tab",
- "read_tab",
- ]);
- expect(out).toEqual(
- expect.arrayContaining([
- "summon",
- "retrieve",
- "web_search",
- "youtube_transcribe",
- "search_code",
- "send_to_tab",
- "read_tab",
- ]),
- );
- });
-
- it("always includes 'todo' even when not requested", () => {
- expect(expandAgentToolNames([])).toContain("todo");
- expect(expandAgentToolNames(["read"])).toContain("todo");
- expect(expandAgentToolNames(["summon"])).toContain("todo");
- });
-
- it("deduplicates when groups overlap with explicit names", () => {
- const out = expandAgentToolNames(["read", "read_file"]);
- // Each name should appear at most once
- const counts = new Map<string, number>();
- for (const t of out) counts.set(t, (counts.get(t) ?? 0) + 1);
- for (const [, c] of counts) expect(c).toBe(1);
- });
-});
-
-describe("getAgentDirPaths", () => {
- it("returns just the global dir when no projectDir is supplied", () => {
- const paths = getAgentDirPaths();
- expect(paths).toHaveLength(1);
- expect(paths[0]).toContain(".config/dispatch/agents");
- });
-
- it("appends the project-scoped dir when projectDir is supplied", () => {
- const paths = getAgentDirPaths("/some/project");
- expect(paths).toHaveLength(2);
- expect(paths[1]).toBe("/some/project/.dispatch/agents");
- });
-});
-
-describe("loadAgent — project-scoped sandbox", () => {
- // `GLOBAL_AGENTS_DIR` is captured at module load via `os.homedir()`
- // and can't be redirected at runtime. The project-scoped path,
- // however, is computed per-call from the `projectDir` argument, so
- // we exercise that branch instead. This is also the more common
- // real-world case (per-project agent definitions).
- let tmpProject: string;
-
- beforeEach(() => {
- tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), "dispatch-loader-test-"));
- });
-
- afterEach(() => {
- fs.rmSync(tmpProject, { recursive: true, force: true });
- });
-
- function writeAgentToml(slug: string, body: string): void {
- const agentsDir = path.join(tmpProject, ".dispatch", "agents");
- fs.mkdirSync(agentsDir, { recursive: true });
- fs.writeFileSync(path.join(agentsDir, `${slug}.toml`), body, "utf-8");
- }
-
- // Uses a slug unlikely to collide with anything the user might
- // already have in ~/.config/dispatch/agents. `loadAgent` returns
- // the FIRST match it finds across all scanned directories, and
- // the global scope is scanned before the project scope — a slug
- // that exists in both would resolve to the global one (which is
- // real, not under our control). The "z-dispatch-test-*" prefix
- // gives this fixture exclusive ownership of the slug.
- const TEST_SLUG = "z-dispatch-test-fixture";
-
- it("returns null for an unknown slug within the project scope", () => {
- const agent = loadAgent("z-dispatch-test-does-not-exist", tmpProject);
- expect(agent).toBeNull();
- });
-
- it("loads a TOML definition written to the project's .dispatch/agents", () => {
- writeAgentToml(
- TEST_SLUG,
- [
- 'name = "Fixture"',
- 'description = "Sandbox fixture for loadAgent test."',
- "skills = []",
- 'tools = ["read", "bash"]',
- "is_subagent = true",
- "",
- "[[models]]",
- 'key_id = "opencode-1"',
- 'model_id = "deepseek-v4-flash"',
- "",
- ].join("\n"),
- );
-
- const agent = loadAgent(TEST_SLUG, tmpProject);
- expect(agent).not.toBeNull();
- expect(agent?.slug).toBe(TEST_SLUG);
- expect(agent?.name).toBe("Fixture");
- expect(agent?.tools).toEqual(["read", "bash"]);
- expect(agent?.is_subagent).toBe(true);
- expect(agent?.models).toEqual([{ key_id: "opencode-1", model_id: "deepseek-v4-flash" }]);
- expect(agent?.scope).toBe(tmpProject);
- });
-
- it("parses a per-model effort when it is a recognised level", () => {
- writeAgentToml(
- TEST_SLUG,
- [
- 'name = "Fixture"',
- "skills = []",
- 'tools = ["read"]',
- "",
- "[[models]]",
- 'key_id = "opencode-1"',
- 'model_id = "deepseek-v4-flash"',
- 'effort = "low"',
- "",
- "[[models]]",
- 'key_id = "claude-max"',
- 'model_id = "claude-opus-4-8"',
- 'effort = "xhigh"',
- "",
- ].join("\n"),
- );
-
- const agent = loadAgent(TEST_SLUG, tmpProject);
- expect(agent?.models).toEqual([
- { key_id: "opencode-1", model_id: "deepseek-v4-flash", effort: "low" },
- { key_id: "claude-max", model_id: "claude-opus-4-8", effort: "xhigh" },
- ]);
- });
-
- it("drops an invalid effort value so the call site falls back to the default", () => {
- writeAgentToml(
- TEST_SLUG,
- [
- 'name = "Fixture"',
- "skills = []",
- 'tools = ["read"]',
- "",
- "[[models]]",
- 'key_id = "opencode-1"',
- 'model_id = "deepseek-v4-flash"',
- 'effort = "turbo"',
- "",
- ].join("\n"),
- );
-
- const agent = loadAgent(TEST_SLUG, tmpProject);
- expect(agent?.models).toEqual([{ key_id: "opencode-1", model_id: "deepseek-v4-flash" }]);
- expect(agent?.models[0]).not.toHaveProperty("effort");
- });
-
- it("round-trips effort through saveAgent → loadAgent", () => {
- saveAgent({
- name: "Fixture",
- description: "",
- skills: [],
- tools: ["read"],
- models: [
- { key_id: "opencode-1", model_id: "deepseek-v4-flash", effort: "medium" },
- { key_id: "claude-max", model_id: "claude-opus-4-8" },
- ],
- scope: tmpProject,
- slug: TEST_SLUG,
- });
-
- const agent = loadAgent(TEST_SLUG, tmpProject);
- expect(agent?.models).toEqual([
- { key_id: "opencode-1", model_id: "deepseek-v4-flash", effort: "medium" },
- { key_id: "claude-max", model_id: "claude-opus-4-8" },
- ]);
- });
-
- it("sanitizes the slug so path traversal can't reach outside the agents dir", () => {
- // Even if a caller passes something gnarly, the lookup is by
- // sanitized slug — no file outside the configured dirs should
- // ever be opened. The sanitized form ("etc-passwd") obviously
- // doesn't exist in the temp project, so the result is null.
- const agent = loadAgent("../../../etc/passwd", tmpProject);
- expect(agent).toBeNull();
- });
-});
diff --git a/packages/core/tests/chunks/append.test.ts b/packages/core/tests/chunks/append.test.ts
deleted file mode 100644
index dc277d2..0000000
--- a/packages/core/tests/chunks/append.test.ts
+++ /dev/null
@@ -1,534 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { appendEventToChunks, applySystemEvent } from "../../src/chunks/append.js";
-import type { AgentEvent, ChatMessage, Chunk } from "../../src/types/index.js";
-
-// ─── helpers ─────────────────────────────────────────────────────
-
-const td = (delta: string): AgentEvent => ({ type: "text-delta", delta });
-const rd = (delta: string): AgentEvent => ({ type: "reasoning-delta", delta });
-const re = (metadata?: Record<string, unknown>): AgentEvent => ({
- type: "reasoning-end",
- ...(metadata !== undefined ? { metadata } : {}),
-});
-const tc = (id: string, name = "fake_tool", args: Record<string, unknown> = {}): AgentEvent => ({
- type: "tool-call",
- toolCall: { id, name, arguments: args },
-});
-const tr = (toolCallId: string, result: string, isError = false): AgentEvent => ({
- type: "tool-result",
- toolResult: { toolCallId, toolName: "fake_tool", result, isError },
-});
-const so = (data: string, stream: "stdout" | "stderr" = "stdout"): AgentEvent => ({
- type: "shell-output",
- data,
- stream,
-});
-const err = (error: string, statusCode?: number): AgentEvent => ({
- type: "error",
- error,
- ...(statusCode !== undefined ? { statusCode } : {}),
-});
-const notice = (message: string): AgentEvent => ({ type: "notice", message });
-const modelChanged = (keyId: string, modelId: string): AgentEvent => ({
- type: "model-changed",
- keyId,
- modelId,
-});
-const configReload: AgentEvent = { type: "config-reload" };
-
-function run(events: AgentEvent[]): Chunk[] {
- const chunks: Chunk[] = [];
- for (const e of events) appendEventToChunks(chunks, e);
- return chunks;
-}
-
-// ─── Required cases from the plan ────────────────────────────────
-
-describe("appendEventToChunks — required cases from plan", () => {
- it("empty chunks + text-delta → one text chunk with the delta", () => {
- const chunks = run([td("Hello")]);
- expect(chunks).toEqual([{ type: "text", text: "Hello" }]);
- });
-
- it("two consecutive text-deltas → one text chunk with concatenated text", () => {
- const chunks = run([td("Hello, "), td("world!")]);
- expect(chunks).toEqual([{ type: "text", text: "Hello, world!" }]);
- });
-
- it("text-delta then reasoning-delta → two chunks (text, thinking)", () => {
- const chunks = run([td("ans: 42"), rd("I should explain")]);
- expect(chunks).toEqual([
- { type: "text", text: "ans: 42" },
- { type: "thinking", text: "I should explain" },
- ]);
- });
-
- it("text-delta then tool-call → two chunks (text, tool-batch with one entry)", () => {
- const chunks = run([td("Looking..."), tc("t1", "read_file", { path: "x" })]);
- expect(chunks).toEqual([
- { type: "text", text: "Looking..." },
- {
- type: "tool-batch",
- calls: [{ id: "t1", name: "read_file", arguments: { path: "x" } }],
- },
- ]);
- });
-
- it("two consecutive tool-calls → one tool-batch with two entries", () => {
- const chunks = run([tc("t1", "read_file"), tc("t2", "list_files")]);
- expect(chunks).toHaveLength(1);
- expect(chunks[0]).toMatchObject({
- type: "tool-batch",
- calls: [
- { id: "t1", name: "read_file" },
- { id: "t2", name: "list_files" },
- ],
- });
- });
-
- it("tool-call then tool-call then text → two chunks (tool-batch with 2 entries, text)", () => {
- const chunks = run([tc("t1"), tc("t2"), td("done")]);
- expect(chunks).toHaveLength(2);
- expect(chunks[0]).toMatchObject({
- type: "tool-batch",
- calls: [{ id: "t1" }, { id: "t2" }],
- });
- expect(chunks[1]).toEqual({ type: "text", text: "done" });
- });
-
- it("tool-result arrives → updates matching tool-call entry in the latest tool-batch chunk by id", () => {
- const chunks = run([
- tc("t1"),
- tc("t2"),
- tr("t1", "first-result"),
- tr("t2", "second-result", true),
- ]);
- expect(chunks).toHaveLength(1);
- const batch = chunks[0];
- expect(batch.type).toBe("tool-batch");
- if (batch.type !== "tool-batch") throw new Error("type guard");
- expect(batch.calls[0]).toMatchObject({ id: "t1", result: "first-result", isError: false });
- expect(batch.calls[1]).toMatchObject({ id: "t2", result: "second-result", isError: true });
- });
-
- it("shell-output arrives → appends to the most recent tool-call's shellOutput", () => {
- const chunks = run([
- tc("t1", "run_shell"),
- so("hello\n", "stdout"),
- so("world\n", "stdout"),
- so("err!\n", "stderr"),
- ]);
- expect(chunks).toHaveLength(1);
- const batch = chunks[0];
- if (batch.type !== "tool-batch") throw new Error("type guard");
- expect(batch.calls[0]?.shellOutput).toEqual({
- stdout: "hello\nworld\n",
- stderr: "err!\n",
- });
- });
-
- it("error event → opens an error chunk; subsequent events go to new chunks", () => {
- const chunks = run([td("partial..."), err("network failed", 503), td("recovery")]);
- expect(chunks).toEqual([
- { type: "text", text: "partial..." },
- { type: "error", message: "network failed", statusCode: 503 },
- { type: "text", text: "recovery" },
- ]);
- });
-
- it("system event during text run → closes text, opens system, would re-open text on next text-delta", () => {
- const chunks = run([td("first "), notice("model swap"), td("second")]);
- expect(chunks).toEqual([
- { type: "text", text: "first " },
- { type: "system", kind: "notice", text: "model swap" },
- { type: "text", text: "second" },
- ]);
- });
-
- it("two consecutive system events → two separate system chunks (no coalescing)", () => {
- const chunks = run([notice("a"), notice("b")]);
- expect(chunks).toEqual([
- { type: "system", kind: "notice", text: "a" },
- { type: "system", kind: "notice", text: "b" },
- ]);
- });
-
- it("interleaved think → text → think → tool → think → text → 6 chunks in order", () => {
- const chunks = run([
- rd("planning..."),
- td("here goes:"),
- rd("hmm, actually"),
- tc("t1", "read_file"),
- rd("ok now"),
- td("and so..."),
- ]);
- expect(chunks.map((c) => c.type)).toEqual([
- "thinking",
- "text",
- "thinking",
- "tool-batch",
- "thinking",
- "text",
- ]);
- expect(chunks[0]).toEqual({ type: "thinking", text: "planning..." });
- expect(chunks[1]).toEqual({ type: "text", text: "here goes:" });
- expect(chunks[2]).toEqual({ type: "thinking", text: "hmm, actually" });
- expect(chunks[3]).toMatchObject({ type: "tool-batch", calls: [{ id: "t1" }] });
- expect(chunks[4]).toEqual({ type: "thinking", text: "ok now" });
- expect(chunks[5]).toEqual({ type: "text", text: "and so..." });
- });
-});
-
-// ─── Additional transition coverage ──────────────────────────────
-
-describe("appendEventToChunks — transition matrix", () => {
- it("thinking → thinking coalesces", () => {
- const chunks = run([rd("a"), rd("b")]);
- expect(chunks).toEqual([{ type: "thinking", text: "ab" }]);
- });
-
- it("thinking → text opens a new text chunk", () => {
- const chunks = run([rd("think"), td("speak")]);
- expect(chunks).toEqual([
- { type: "thinking", text: "think" },
- { type: "text", text: "speak" },
- ]);
- });
-
- it("tool-batch → text opens a new text chunk", () => {
- const chunks = run([tc("t1"), td("after tool")]);
- expect(chunks).toHaveLength(2);
- expect(chunks[1]).toEqual({ type: "text", text: "after tool" });
- });
-
- it("text → reasoning-delta after a multi-delta text run still splits cleanly", () => {
- const chunks = run([td("a"), td("b"), rd("x"), rd("y"), td("c")]);
- expect(chunks).toEqual([
- { type: "text", text: "ab" },
- { type: "thinking", text: "xy" },
- { type: "text", text: "c" },
- ]);
- });
-
- it("error → text opens a fresh text chunk after the error", () => {
- const chunks = run([err("boom"), td("recovered")]);
- expect(chunks).toEqual([
- { type: "error", message: "boom" },
- { type: "text", text: "recovered" },
- ]);
- });
-
- it("two consecutive errors stay as two error chunks (no coalescing)", () => {
- const chunks = run([err("first"), err("second", 429)]);
- expect(chunks).toEqual([
- { type: "error", message: "first" },
- { type: "error", message: "second", statusCode: 429 },
- ]);
- });
-
- it("system → tool-call opens a new tool-batch (does not extend the system chunk)", () => {
- const chunks = run([notice("info"), tc("t1")]);
- expect(chunks).toHaveLength(2);
- expect(chunks[1]).toMatchObject({ type: "tool-batch", calls: [{ id: "t1" }] });
- });
-
- it("tool-result with no matching call is silently dropped", () => {
- const chunks = run([td("hi"), tr("no-such-id", "ignored")]);
- expect(chunks).toEqual([{ type: "text", text: "hi" }]);
- });
-
- it("shell-output with no tool-batch in scope is silently dropped", () => {
- const chunks = run([td("hi"), so("orphan")]);
- expect(chunks).toEqual([{ type: "text", text: "hi" }]);
- });
-
- it("tool-result for an earlier batch still updates the right call (results can arrive late)", () => {
- // Order: tc -> td -> tc(new batch) -> tr(for first batch's id)
- const chunks = run([
- tc("t1", "read_file"),
- td("midstream text"),
- tc("t2", "list_files"),
- tr("t1", "late result for first"),
- ]);
- // Two tool-batches, separated by the text chunk. The result must land
- // inside the FIRST batch (the one containing t1), not the most-recent.
- expect(chunks.map((c) => c.type)).toEqual(["tool-batch", "text", "tool-batch"]);
- const first = chunks[0];
- if (first?.type !== "tool-batch") throw new Error("type guard");
- expect(first.calls[0]).toMatchObject({ id: "t1", result: "late result for first" });
- const second = chunks[2];
- if (second?.type !== "tool-batch") throw new Error("type guard");
- // t2 in the second batch has no result yet.
- expect(second.calls[0]?.result).toBeUndefined();
- });
-
- it("shell-output goes to the most recent tool-batch's most recent entry, even with intervening chunks", () => {
- // First batch's tool runs, emits output, then later a second batch starts and emits output.
- const chunks = run([
- tc("t1", "run_shell"),
- so("first-stdout\n"),
- td("interlude"),
- tc("t2", "run_shell"),
- so("second-stdout\n"),
- ]);
- expect(chunks.map((c) => c.type)).toEqual(["tool-batch", "text", "tool-batch"]);
- const first = chunks[0];
- const second = chunks[2];
- if (first?.type !== "tool-batch" || second?.type !== "tool-batch") {
- throw new Error("type guard");
- }
- expect(first.calls[0]?.shellOutput).toEqual({ stdout: "first-stdout\n", stderr: "" });
- expect(second.calls[0]?.shellOutput).toEqual({ stdout: "second-stdout\n", stderr: "" });
- });
-
- it("model-changed event opens a system chunk with kind=model-changed", () => {
- const chunks = run([modelChanged("anthropic-1", "claude-sonnet-4")]);
- expect(chunks).toEqual([
- {
- type: "system",
- kind: "model-changed",
- text: "Switched to claude-sonnet-4 (anthropic-1)",
- },
- ]);
- });
-
- it("config-reload event opens a system chunk with kind=config-reload", () => {
- const chunks = run([configReload]);
- expect(chunks).toEqual([
- { type: "system", kind: "config-reload", text: "Configuration reloaded" },
- ]);
- });
-
- // ─── reasoning-end (v6 SDK metadata round-trip) ──────────────────
-
- it("reasoning-delta then reasoning-end seals the thinking chunk with metadata", () => {
- const meta = { anthropic: { signature: "sig-1" } };
- const chunks = run([rd("plan"), re(meta)]);
- expect(chunks).toEqual([{ type: "thinking", text: "plan", metadata: meta }]);
- });
-
- it("two reasoning-deltas then reasoning-end coalesces text and seals once", () => {
- const meta = { anthropic: { signature: "abc" } };
- const chunks = run([rd("a"), rd("b"), re(meta)]);
- expect(chunks).toEqual([{ type: "thinking", text: "ab", metadata: meta }]);
- });
-
- it("reasoning-delta → reasoning-end → reasoning-delta opens a NEW chunk", () => {
- // Each Anthropic thinking content block gets its own metadata.
- // Extending a sealed chunk would corrupt the text/metadata mapping.
- const meta1 = { anthropic: { signature: "sig-1" } };
- const chunks = run([rd("first"), re(meta1), rd("second")]);
- expect(chunks).toEqual([
- { type: "thinking", text: "first", metadata: meta1 },
- { type: "thinking", text: "second" },
- ]);
- });
-
- it("rd → re → rd → re produces two independently sealed chunks", () => {
- const m1 = { anthropic: { signature: "s1" } };
- const m2 = { anthropic: { signature: "s2" } };
- const chunks = run([rd("first"), re(m1), rd("second"), re(m2)]);
- expect(chunks).toEqual([
- { type: "thinking", text: "first", metadata: m1 },
- { type: "thinking", text: "second", metadata: m2 },
- ]);
- });
-
- it("orphan reasoning-end (no prior thinking chunk) is a no-op", () => {
- const chunks = run([re({ anthropic: { signature: "orphan" } })]);
- expect(chunks).toEqual([]);
- });
-
- it("reasoning-end after an already-sealed thinking chunk does NOT overwrite", () => {
- const m1 = { anthropic: { signature: "first" } };
- const m2 = { anthropic: { signature: "second" } };
- const chunks = run([rd("a"), re(m1), re(m2)]);
- expect(chunks).toEqual([{ type: "thinking", text: "a", metadata: m1 }]);
- });
-
- it("reasoning-end without metadata is a silent no-op (does not seal)", () => {
- // v6 may emit reasoning-end with no providerMetadata for
- // non-Anthropic providers. Don't seal those chunks — a subsequent
- // reasoning-delta should continue extending.
- const chunks = run([rd("hello"), re(), rd(" world")]);
- expect(chunks).toEqual([{ type: "thinking", text: "hello world" }]);
- });
-
- it("re walks back across an intervening text chunk to seal the right thinking", () => {
- // Defensive: even if a non-thinking chunk lands between the
- // reasoning text and its end-event, the metadata still attaches
- // to the unsealed thinking chunk.
- const meta = { anthropic: { signature: "late" } };
- const chunks = run([rd("plan"), td("midstream"), re(meta)]);
- expect(chunks).toEqual([
- { type: "thinking", text: "plan", metadata: meta },
- { type: "text", text: "midstream" },
- ]);
- });
-
- it("interleaved rd / re / tool-call / rd / re produces correct chunk sequence", () => {
- // Anthropic's interleaved-thinking emits a thinking block, then
- // a tool call, then another thinking block. Each thinking block
- // gets its own metadata.
- const m1 = { anthropic: { signature: "before-tool" } };
- const m2 = { anthropic: { signature: "after-tool" } };
- const chunks = run([
- rd("plan tool"),
- re(m1),
- tc("t1", "read_file"),
- rd("plan response"),
- re(m2),
- ]);
- expect(chunks.map((c) => c.type)).toEqual(["thinking", "tool-batch", "thinking"]);
- expect(chunks[0]).toEqual({
- type: "thinking",
- text: "plan tool",
- metadata: m1,
- });
- expect(chunks[2]).toEqual({
- type: "thinking",
- text: "plan response",
- metadata: m2,
- });
- });
-
- it("non-content events (status / done / task-list-update / message-queued etc.) are no-ops", () => {
- const chunks = run([
- td("hello"),
- { type: "status", status: "running" },
- { type: "task-list-update", tasks: [] },
- {
- type: "tab-created",
- id: "tab1",
- title: "x",
- keyId: null,
- modelId: null,
- parentTabId: null,
- workingDirectory: null,
- },
- { type: "message-queued", tabId: "t", messageId: "m", message: "queued" },
- { type: "message-consumed", tabId: "t", messageIds: ["m"] },
- { type: "message-cancelled", tabId: "t", messageId: "m" },
- {
- type: "done",
- message: { role: "assistant", chunks: [] },
- },
- td(" world"),
- ]);
- expect(chunks).toEqual([{ type: "text", text: "hello world" }]);
- });
-
- it("error chunk omits statusCode when not provided", () => {
- const chunks = run([err("boom")]);
- expect(chunks).toEqual([{ type: "error", message: "boom" }]);
- // And no stray statusCode key:
- expect(Object.hasOwn(chunks[0], "statusCode")).toBe(false);
- });
-
- it("tool-result updates isError=false correctly (default success path)", () => {
- const chunks = run([tc("t1"), tr("t1", "ok", false)]);
- const batch = chunks[0];
- if (batch?.type !== "tool-batch") throw new Error("type guard");
- expect(batch.calls[0]).toMatchObject({ result: "ok", isError: false });
- });
-});
-
-// ─── applySystemEvent routing ────────────────────────────────────
-
-describe("applySystemEvent", () => {
- type Msg = { id: string; role: "user" | "assistant" | "system"; chunks: Chunk[] };
-
- let counter = 0;
- const idFactory = () => `gen-${++counter}`;
-
- it("creates a new role:system message when message list is empty", () => {
- counter = 0;
- const messages: Msg[] = [];
- const result = applySystemEvent(messages, { kind: "notice", text: "hello" }, idFactory);
- expect(result.messageId).toBe("gen-1");
- expect(messages).toEqual([
- {
- id: "gen-1",
- role: "system",
- chunks: [{ type: "system", kind: "notice", text: "hello" }],
- },
- ]);
- });
-
- it("creates a new role:system message when last message is user", () => {
- counter = 0;
- const messages: Msg[] = [{ id: "u1", role: "user", chunks: [{ type: "text", text: "hi" }] }];
- const result = applySystemEvent(messages, { kind: "model-changed", text: "swap" }, idFactory);
- expect(result.messageId).toBe("gen-1");
- expect(messages).toHaveLength(2);
- expect(messages[1]).toMatchObject({
- id: "gen-1",
- role: "system",
- chunks: [{ type: "system", kind: "model-changed", text: "swap" }],
- });
- });
-
- it("creates a new role:system message when last message is assistant", () => {
- counter = 0;
- const messages: Msg[] = [
- { id: "a1", role: "assistant", chunks: [{ type: "text", text: "done" }] },
- ];
- applySystemEvent(messages, { kind: "config-reload", text: "reloaded" }, idFactory);
- expect(messages).toHaveLength(2);
- expect(messages[1]?.role).toBe("system");
- });
-
- it("appends a chunk to the existing system message when last message is role:system", () => {
- counter = 0;
- const messages: Msg[] = [
- {
- id: "s1",
- role: "system",
- chunks: [{ type: "system", kind: "notice", text: "first" }],
- },
- ];
- const result = applySystemEvent(messages, { kind: "notice", text: "second" }, idFactory);
- expect(result.messageId).toBe("s1");
- expect(messages).toHaveLength(1);
- expect(messages[0]?.chunks).toEqual([
- { type: "system", kind: "notice", text: "first" },
- { type: "system", kind: "notice", text: "second" },
- ]);
- });
-
- it("multiple consecutive calls accumulate in the same system message", () => {
- counter = 0;
- const messages: Msg[] = [{ id: "u1", role: "user", chunks: [{ type: "text", text: "hi" }] }];
- applySystemEvent(messages, { kind: "notice", text: "a" }, idFactory);
- applySystemEvent(messages, { kind: "notice", text: "b" }, idFactory);
- applySystemEvent(messages, { kind: "model-changed", text: "c" }, idFactory);
- expect(messages).toHaveLength(2);
- const sys = messages[1];
- expect(sys?.role).toBe("system");
- expect(sys?.chunks).toEqual([
- { type: "system", kind: "notice", text: "a" },
- { type: "system", kind: "notice", text: "b" },
- { type: "system", kind: "model-changed", text: "c" },
- ]);
- });
-
- it("returns the same messageId across appends to the same system message", () => {
- counter = 0;
- const messages: Msg[] = [];
- const first = applySystemEvent(messages, { kind: "notice", text: "a" }, idFactory);
- const second = applySystemEvent(messages, { kind: "notice", text: "b" }, idFactory);
- expect(first.messageId).toBe(second.messageId);
- });
-
- it("works against the core ChatMessage shape (with id added by caller)", () => {
- // Sanity: ChatMessage has {role, chunks}; the caller layers id on top.
- // This test exists to prove the generic constraint doesn't reject the
- // real persistence/in-memory shape we'll see in Phase 5.
- counter = 0;
- const messages: Array<ChatMessage & { id: string }> = [];
- const result = applySystemEvent(messages, { kind: "cancelled", text: "user stop" }, idFactory);
- expect(result.messageId).toBe("gen-1");
- expect(messages[0]?.role).toBe("system");
- expect(messages[0]?.chunks[0]).toMatchObject({ kind: "cancelled", text: "user stop" });
- });
-});
diff --git a/packages/core/tests/compaction/compaction.test.ts b/packages/core/tests/compaction/compaction.test.ts
deleted file mode 100644
index d6edd59..0000000
--- a/packages/core/tests/compaction/compaction.test.ts
+++ /dev/null
@@ -1,194 +0,0 @@
-import { describe, expect, it } from "vitest";
-import {
- buildCompactionPrompt,
- buildCompactionRequest,
- buildSummaryTurnText,
- DEFAULT_TAIL_TURNS,
- extractPreviousSummary,
- renderTranscript,
- SUMMARY_MARKER,
- SUMMARY_TEMPLATE,
- selectHeadTail,
-} from "../../src/compaction/index.js";
-import type { ChatMessage } from "../../src/types/index.js";
-
-function user(text: string): ChatMessage {
- return { role: "user", chunks: [{ type: "text", text }] };
-}
-function assistant(text: string): ChatMessage {
- return { role: "assistant", chunks: [{ type: "text", text }] };
-}
-
-describe("selectHeadTail", () => {
- it("returns empty head when turns <= tailTurns (nothing to compact)", () => {
- const msgs = [user("u1"), assistant("a1"), user("u2"), assistant("a2")];
- const { head, tail } = selectHeadTail(msgs, 2);
- expect(head).toEqual([]);
- expect(tail).toEqual(msgs);
- });
-
- it("keeps the last N turns verbatim and summarizes the rest", () => {
- const msgs = [
- user("u1"),
- assistant("a1"),
- user("u2"),
- assistant("a2"),
- user("u3"),
- assistant("a3"),
- ];
- const { head, tail } = selectHeadTail(msgs, 2);
- expect(tail[0]).toBe(msgs[2]);
- expect(tail.at(-1)).toBe(msgs[5]);
- expect(head).toEqual([msgs[0], msgs[1]]);
- });
-
- it("a turn includes trailing assistant/tool messages up to the next user", () => {
- const msgs = [user("u1"), assistant("a1a"), assistant("a1b"), user("u2"), assistant("a2")];
- const { head, tail } = selectHeadTail(msgs, 1);
- expect(head).toEqual([msgs[0], msgs[1], msgs[2]]);
- expect(tail).toEqual([msgs[3], msgs[4]]);
- });
-
- it("tailTurns<=0 → everything is head", () => {
- const msgs = [user("u1"), assistant("a1")];
- expect(selectHeadTail(msgs, 0)).toEqual({ head: msgs, tail: [] });
- });
-
- it("defaults to DEFAULT_TAIL_TURNS", () => {
- const msgs = [user("u1"), user("u2"), user("u3")];
- const def = selectHeadTail(msgs);
- const explicit = selectHeadTail(msgs, DEFAULT_TAIL_TURNS);
- expect(def).toEqual(explicit);
- });
-});
-
-describe("buildCompactionPrompt", () => {
- it("creates a fresh-summary instruction without a previous summary", () => {
- const p = buildCompactionPrompt({});
- expect(p).toContain("Create a new anchored summary");
- expect(p).toContain(SUMMARY_TEMPLATE);
- expect(p).not.toContain("<previous-summary>");
- });
-
- it("anchors on a previous summary when provided", () => {
- const p = buildCompactionPrompt({ previousSummary: "## Goal\n- old" });
- expect(p).toContain("Update the anchored summary");
- expect(p).toContain("<previous-summary>");
- expect(p).toContain("## Goal\n- old");
- expect(p).toContain(SUMMARY_TEMPLATE);
- });
-});
-
-describe("extractPreviousSummary", () => {
- it("returns undefined when the first user message is not a seeded summary", () => {
- expect(extractPreviousSummary([user("hello"), assistant("hi")])).toBeUndefined();
- });
-
- it("extracts the body of a seeded summary turn (marker stripped)", () => {
- const seeded = user(buildSummaryTurnText("## Goal\n- build X"));
- expect(extractPreviousSummary([seeded, assistant("ok")])).toBe("## Goal\n- build X");
- });
-});
-
-describe("renderTranscript", () => {
- it("renders user/assistant text blocks", () => {
- const t = renderTranscript([user("hello"), assistant("hi there")]);
- expect(t).toContain("## User\nhello");
- expect(t).toContain("## Assistant\nhi there");
- });
-
- it("renders tool calls and caps long tool results", () => {
- const big = "x".repeat(5000);
- const msg: ChatMessage = {
- role: "assistant",
- chunks: [
- {
- type: "tool-batch",
- calls: [{ id: "c1", name: "read_file", arguments: { path: "a" }, result: big }],
- },
- ],
- };
- const t = renderTranscript([msg], 2000);
- expect(t).toContain('[tool read_file {"path":"a"}]');
- expect(t).toContain("chars truncated for summary");
- expect(t.length).toBeLessThan(5000 + 200);
- });
-
- it("skips a seeded prior-summary user turn", () => {
- const seeded = user(buildSummaryTurnText("## Goal\n- prior"));
- const t = renderTranscript([seeded, assistant("work")]);
- expect(t).not.toContain("prior");
- expect(t).toContain("## Assistant\nwork");
- });
-
- it("omits thinking/error/system chunks", () => {
- const msg: ChatMessage = {
- role: "assistant",
- chunks: [
- { type: "thinking", text: "secret reasoning" },
- { type: "text", text: "visible" },
- { type: "error", message: "boom" },
- ],
- };
- const t = renderTranscript([msg]);
- expect(t).toContain("visible");
- expect(t).not.toContain("secret reasoning");
- expect(t).not.toContain("boom");
- });
-});
-
-describe("buildCompactionRequest", () => {
- it("returns no prompt when there is nothing to compact", () => {
- const msgs = [user("u1"), assistant("a1")];
- const req = buildCompactionRequest({ messages: msgs, tailTurns: 2 });
- expect(req.prompt).toBeUndefined();
- expect(req.head).toEqual([]);
- expect(req.tail).toEqual(msgs);
- });
-
- it("builds a prompt with transcript + instruction and exposes head/tail", () => {
- const msgs = [
- user("first task"),
- assistant("did stuff"),
- user("second"),
- assistant("more"),
- user("third"),
- assistant("done"),
- ];
- const req = buildCompactionRequest({ messages: msgs, tailTurns: 2 });
- expect(req.prompt).toBeDefined();
- expect(req.prompt).toContain("first task");
- expect(req.prompt).toContain("Create a new anchored summary");
- expect(req.tail[0]).toBe(msgs[2]);
- expect(req.head[0]).toBe(msgs[0]);
- });
-
- it("anchors on a prior seeded summary", () => {
- const msgs = [
- user(buildSummaryTurnText("## Goal\n- old goal")),
- assistant("ack"),
- user("new work"),
- assistant("did new"),
- user("more work"),
- assistant("did more"),
- ];
- const req = buildCompactionRequest({ messages: msgs, tailTurns: 2 });
- expect(req.previousSummary).toBe("## Goal\n- old goal");
- expect(req.prompt).toContain("Update the anchored summary");
- // head = [seeded-summary, "ack"]; seeded summary is skipped in the
- // transcript, so "ack" represents the summarized head. "new work" lives
- // in the preserved tail (last 2 turns), not the summary body.
- expect(req.prompt).toContain("ack");
- expect(
- req.tail.some((m) => m.chunks.some((c) => c.type === "text" && c.text === "new work")),
- ).toBe(true);
- });
-});
-
-describe("buildSummaryTurnText", () => {
- it("prefixes the marker so a later compaction can anchor", () => {
- const seeded = buildSummaryTurnText("## Goal\n- x");
- expect(seeded.startsWith(SUMMARY_MARKER)).toBe(true);
- expect(extractPreviousSummary([user(seeded)])).toBe("## Goal\n- x");
- });
-});
diff --git a/packages/core/tests/config/loader.test.ts b/packages/core/tests/config/loader.test.ts
deleted file mode 100644
index 0d84d0b..0000000
--- a/packages/core/tests/config/loader.test.ts
+++ /dev/null
@@ -1,176 +0,0 @@
-import { mkdirSync, rmSync, writeFileSync } from "node:fs";
-import { homedir } from "node:os";
-import { join, sep } from "node:path";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { configToRuleset, loadConfig } from "../../src/config/loader.js";
-
-const TMP = join("/tmp/opencode", "dispatch-config-test");
-
-// Point the global config at a path that does not exist so these tests are
-// hermetic — they must not pick up this machine's real
-// ~/.config/dispatch/dispatch.toml.
-const prevGlobal = process.env.DISPATCH_GLOBAL_CONFIG;
-
-beforeEach(() => {
- mkdirSync(TMP, { recursive: true });
- process.env.DISPATCH_GLOBAL_CONFIG = join(TMP, "__no_such_global__.toml");
-});
-
-afterEach(() => {
- rmSync(TMP, { recursive: true, force: true });
- if (prevGlobal === undefined) delete process.env.DISPATCH_GLOBAL_CONFIG;
- else process.env.DISPATCH_GLOBAL_CONFIG = prevGlobal;
-});
-
-function writeToml(content: string): void {
- writeFileSync(join(TMP, "dispatch.toml"), content, "utf-8");
-}
-
-describe("loadConfig", () => {
- it("returns empty permissions when dispatch.toml is missing", () => {
- const config = loadConfig(TMP);
- expect(config.permissions).toEqual({});
- });
-
- it("parses simple string permissions", () => {
- writeToml(`[permissions]\nread = "allow"\nedit = "deny"\n`);
- const config = loadConfig(TMP);
- expect(config.permissions.read).toBe("allow");
- expect(config.permissions.edit).toBe("deny");
- });
-
- it("parses nested pattern permissions", () => {
- writeToml(`[permissions.bash]\n"npm test" = "allow"\n"*" = "ask"\n`);
- const config = loadConfig(TMP);
- const bash = config.permissions.bash as Record<string, string>;
- expect(bash["npm test"]).toBe("allow");
- expect(bash["*"]).toBe("ask");
- });
-
- it("ignores comment lines", () => {
- writeToml(`# this is a comment\n[permissions]\n# another comment\nread = "allow"\n`);
- const config = loadConfig(TMP);
- expect(config.permissions.read).toBe("allow");
- });
-
- it("handles ~ expansion in nested keys", () => {
- writeToml(`[permissions.read]\n"~/projects/*" = "allow"\n`);
- const config = loadConfig(TMP);
- const read = config.permissions.read as Record<string, string>;
- expect(read["~/projects/*"]).toBe("allow");
- });
-
- it("handles $HOME expansion in nested keys", () => {
- writeToml(`[permissions.read]\n"$HOME/docs/*" = "allow"\n`);
- const config = loadConfig(TMP);
- const read = config.permissions.read as Record<string, string>;
- expect(read["$HOME/docs/*"]).toBe("allow");
- });
-
- it("parses quoted keys", () => {
- writeToml(`[permissions.bash]\n"git commit *" = "allow"\n"rm *" = "deny"\n`);
- const config = loadConfig(TMP);
- const bash = config.permissions.bash as Record<string, string>;
- expect(bash["git commit *"]).toBe("allow");
- expect(bash["rm *"]).toBe("deny");
- });
-
- it("handles multiple permission groups", () => {
- writeToml(
- `[permissions]\nread = "allow"\n\n[permissions.edit]\n"*" = "ask"\n"src/**" = "allow"\n\n[permissions.bash]\n"npm test" = "allow"\n"*" = "ask"\n`,
- );
- const config = loadConfig(TMP);
- expect(config.permissions.read).toBe("allow");
- const edit = config.permissions.edit as Record<string, string>;
- expect(edit["*"]).toBe("ask");
- expect(edit["src/**"]).toBe("allow");
- const bash = config.permissions.bash as Record<string, string>;
- expect(bash["npm test"]).toBe("allow");
- expect(bash["*"]).toBe("ask");
- });
-
- it("preserves # inside quoted string keys", () => {
- writeToml(`[permissions.bash]\n"file#1" = "allow"\n`);
- const config = loadConfig(TMP);
- const bash = config.permissions.bash as Record<string, string>;
- expect(bash["file#1"]).toBe("allow");
- });
-
- it("strips inline comments on table headers", () => {
- writeToml(`[permissions.bash] # scripts\n"*" = "allow"\n`);
- const config = loadConfig(TMP);
- const bash = config.permissions.bash as Record<string, string>;
- expect(bash["*"]).toBe("allow");
- });
-
- it("expands ~ with platform path separator", () => {
- // Simulate a path using the OS separator
- const pattern = `~${sep}projects${sep}*`;
- writeToml(`[permissions.read]\n"${pattern}" = "allow"\n`);
- const config = loadConfig(TMP);
- const read = config.permissions.read as Record<string, string>;
- expect(read[pattern]).toBe("allow");
- });
-
- it("throws on TOML parse errors", () => {
- writeToml("this is not valid TOML [[[");
- expect(() => loadConfig(TMP)).toThrow();
- });
-});
-
-describe("configToRuleset — new validations", () => {
- it("falls back to ask and warns for invalid action in string value", () => {
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
- const rules = configToRuleset({ permissions: { read: "allw" } });
- expect(rules[0]?.action).toBe("ask");
- expect(warn).toHaveBeenCalledWith(expect.stringContaining("allw"));
- warn.mockRestore();
- });
-
- it("falls back to ask and warns for invalid action in nested pattern", () => {
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
- const rules = configToRuleset({ permissions: { bash: { "*": "INVALID" } } });
- expect(rules[0]?.action).toBe("ask");
- expect(warn).toHaveBeenCalledWith(expect.stringContaining("INVALID"));
- warn.mockRestore();
- });
-
- it("expands ~ with backslash separator in patterns", () => {
- const home = homedir();
- // Force backslash path even on Linux to test the regex
- const rules = configToRuleset({ permissions: { read: { "~\\foo\\*": "allow" } } });
- expect(rules[0]?.pattern).toBe(`${home}\\foo\\*`);
- });
-});
-
-describe("configToRuleset", () => {
- it("produces a rule with pattern * for string value", () => {
- const rules = configToRuleset({ permissions: { read: "allow" } });
- expect(rules).toEqual([{ permission: "read", pattern: "*", action: "allow" }]);
- });
-
- it("produces rules for each pattern in an object value", () => {
- const rules = configToRuleset({
- permissions: { bash: { "npm test": "allow", "*": "ask" } },
- });
- expect(rules).toContainEqual({ permission: "bash", pattern: "npm test", action: "allow" });
- expect(rules).toContainEqual({ permission: "bash", pattern: "*", action: "ask" });
- });
-
- it("expands ~ in patterns", () => {
- const home = homedir();
- const rules = configToRuleset({ permissions: { read: { "~/foo/*": "allow" } } });
- expect(rules[0]?.pattern).toBe(`${home}/foo/*`);
- });
-
- it("expands $HOME in patterns", () => {
- const home = homedir();
- const rules = configToRuleset({ permissions: { read: { "$HOME/bar/*": "deny" } } });
- expect(rules[0]?.pattern).toBe(`${home}/bar/*`);
- });
-
- it("handles empty permissions", () => {
- const rules = configToRuleset({ permissions: {} });
- expect(rules).toEqual([]);
- });
-});
diff --git a/packages/core/tests/config/lsp-schema.test.ts b/packages/core/tests/config/lsp-schema.test.ts
deleted file mode 100644
index 2b71cc2..0000000
--- a/packages/core/tests/config/lsp-schema.test.ts
+++ /dev/null
@@ -1,110 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { validateConfig } from "../../src/config/schema.js";
-
-describe("config schema — [lsp] block", () => {
- it("parses a valid custom server entry", () => {
- const { config, errors } = validateConfig({
- permissions: {},
- lsp: {
- "luau-lsp": {
- command: ["luau-lsp", "lsp"],
- extensions: [".luau"],
- initialization: { "luau-lsp": { platform: { type: "roblox" } } },
- },
- },
- });
- expect(errors).toHaveLength(0);
- expect(config.lsp).toBeDefined();
- const entry = config.lsp?.["luau-lsp"];
- expect(entry?.command).toEqual(["luau-lsp", "lsp"]);
- expect(entry?.extensions).toEqual([".luau"]);
- expect(entry?.initialization).toEqual({
- "luau-lsp": { platform: { type: "roblox" } },
- });
- });
-
- it("preserves env and nested initialization verbatim", () => {
- const { config } = validateConfig({
- permissions: {},
- lsp: {
- "luau-lsp": {
- command: ["luau-lsp", "lsp"],
- extensions: [".luau"],
- env: { PATH: "/custom/bin" },
- initialization: {
- "luau-lsp": {
- sourcemap: { enabled: true, autogenerate: true },
- diagnostics: { strictDatamodelTypes: false },
- },
- },
- },
- },
- });
- const entry = config.lsp?.["luau-lsp"];
- expect(entry?.env).toEqual({ PATH: "/custom/bin" });
- expect(entry?.initialization).toEqual({
- "luau-lsp": {
- sourcemap: { enabled: true, autogenerate: true },
- diagnostics: { strictDatamodelTypes: false },
- },
- });
- });
-
- it("rejects a custom server missing command", () => {
- const { config, errors } = validateConfig({
- permissions: {},
- lsp: { broken: { extensions: [".luau"] } },
- });
- expect(errors.some((e) => e.path === "lsp.broken.command")).toBe(true);
- expect(config.lsp).toBeUndefined();
- });
-
- it("rejects a custom server missing extensions", () => {
- const { errors } = validateConfig({
- permissions: {},
- lsp: { broken: { command: ["x"] } },
- });
- expect(errors.some((e) => e.path === "lsp.broken.extensions")).toBe(true);
- });
-
- it("rejects an empty command array", () => {
- const { errors } = validateConfig({
- permissions: {},
- lsp: { broken: { command: [], extensions: [".luau"] } },
- });
- expect(errors.some((e) => e.path === "lsp.broken.command")).toBe(true);
- });
-
- it("keeps a disabled entry without requiring command/extensions", () => {
- const { config, errors } = validateConfig({
- permissions: {},
- lsp: { "luau-lsp": { disabled: true } },
- });
- expect(errors).toHaveLength(0);
- expect(config.lsp?.["luau-lsp"]?.disabled).toBe(true);
- });
-
- it("skips a malformed entry but keeps valid siblings", () => {
- const { config, errors } = validateConfig({
- permissions: {},
- lsp: {
- good: { command: ["a"], extensions: [".luau"] },
- bad: { extensions: [".luau"] },
- },
- });
- expect(config.lsp?.good).toBeDefined();
- expect(config.lsp?.bad).toBeUndefined();
- expect(errors.length).toBeGreaterThan(0);
- });
-
- it("omits lsp entirely when not present", () => {
- const { config, errors } = validateConfig({ permissions: {} });
- expect(errors).toHaveLength(0);
- expect(config.lsp).toBeUndefined();
- });
-
- it("flags a non-object lsp value", () => {
- const { errors } = validateConfig({ permissions: {}, lsp: "nope" });
- expect(errors.some((e) => e.path === "lsp")).toBe(true);
- });
-});
diff --git a/packages/core/tests/config/merge.test.ts b/packages/core/tests/config/merge.test.ts
deleted file mode 100644
index b9e4bbb..0000000
--- a/packages/core/tests/config/merge.test.ts
+++ /dev/null
@@ -1,223 +0,0 @@
-import { mkdirSync, rmSync, writeFileSync } from "node:fs";
-import { join } from "node:path";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import {
- configToRuleset,
- getGlobalConfigPath,
- loadConfig,
- loadGlobalConfig,
- mergeConfigs,
-} from "../../src/config/loader.js";
-import { evaluate } from "../../src/permission/evaluate.js";
-import type { DispatchConfig } from "../../src/types/index.js";
-
-const TMP = join("/tmp/opencode", "dispatch-config-merge-test");
-const LOCAL_DIR = join(TMP, "project");
-const GLOBAL_PATH = join(TMP, "global", "dispatch.toml");
-
-const prevGlobal = process.env.DISPATCH_GLOBAL_CONFIG;
-
-beforeEach(() => {
- mkdirSync(LOCAL_DIR, { recursive: true });
- mkdirSync(join(TMP, "global"), { recursive: true });
- process.env.DISPATCH_GLOBAL_CONFIG = GLOBAL_PATH;
-});
-
-afterEach(() => {
- rmSync(TMP, { recursive: true, force: true });
- if (prevGlobal === undefined) delete process.env.DISPATCH_GLOBAL_CONFIG;
- else process.env.DISPATCH_GLOBAL_CONFIG = prevGlobal;
-});
-
-function writeGlobal(content: string): void {
- writeFileSync(GLOBAL_PATH, content, "utf-8");
-}
-
-function writeLocal(content: string): void {
- writeFileSync(join(LOCAL_DIR, "dispatch.toml"), content, "utf-8");
-}
-
-// ─── mergeConfigs (pure) ─────────────────────────────────────────
-
-describe("mergeConfigs — lsp by id", () => {
- it("keeps non-conflicting servers from both global and local", () => {
- const global: DispatchConfig = {
- permissions: {},
- lsp: { biome: { command: ["biome"], extensions: [".ts"] } },
- };
- const local: DispatchConfig = {
- permissions: {},
- lsp: { luau: { command: ["luau-lsp"], extensions: [".luau"] } },
- };
- const merged = mergeConfigs(global, local);
- expect(Object.keys(merged.lsp ?? {}).sort()).toEqual(["biome", "luau"]);
- });
-
- it("local overrides global for the same server id", () => {
- const global: DispatchConfig = {
- permissions: {},
- lsp: { biome: { command: ["global-biome"], extensions: [".ts"] } },
- };
- const local: DispatchConfig = {
- permissions: {},
- lsp: { biome: { command: ["local-biome"], extensions: [".ts", ".tsx"] } },
- };
- const merged = mergeConfigs(global, local);
- expect(merged.lsp?.biome.command).toEqual(["local-biome"]);
- expect(merged.lsp?.biome.extensions).toEqual([".ts", ".tsx"]);
- });
-
- it("omits lsp entirely when neither side declares one", () => {
- const merged = mergeConfigs({ permissions: {} }, { permissions: {} });
- expect(merged.lsp).toBeUndefined();
- });
-
- it("does not mutate inputs", () => {
- const global: DispatchConfig = {
- permissions: {},
- lsp: { biome: { command: ["g"], extensions: [".ts"] } },
- };
- const local: DispatchConfig = {
- permissions: {},
- lsp: { biome: { command: ["l"], extensions: [".ts"] } },
- };
- mergeConfigs(global, local);
- expect(global.lsp?.biome.command).toEqual(["g"]);
- expect(local.lsp?.biome.command).toEqual(["l"]);
- });
-});
-
-describe("mergeConfigs — keys by id", () => {
- it("merges keys by id, local overriding global", () => {
- const global: DispatchConfig = {
- permissions: {},
- keys: [
- { id: "a", provider: "x", base_url: "g-a" },
- { id: "b", provider: "x", base_url: "g-b" },
- ],
- };
- const local: DispatchConfig = {
- permissions: {},
- keys: [
- { id: "b", provider: "x", base_url: "l-b" },
- { id: "c", provider: "x", base_url: "l-c" },
- ],
- };
- const merged = mergeConfigs(global, local);
- const byId = Object.fromEntries((merged.keys ?? []).map((k) => [k.id, k.base_url]));
- expect(byId).toEqual({ a: "g-a", b: "l-b", c: "l-c" });
- });
-
- it("carries global keys through when local has none", () => {
- const global: DispatchConfig = {
- permissions: {},
- keys: [{ id: "a", provider: "x", base_url: "g-a" }],
- };
- const merged = mergeConfigs(global, { permissions: {} });
- expect(merged.keys).toEqual([{ id: "a", provider: "x", base_url: "g-a" }]);
- });
-});
-
-describe("mergeConfigs — permissions", () => {
- it("merges nested groups pattern-by-pattern with local winning", () => {
- const global: DispatchConfig = {
- permissions: { bash: { "git status": "allow", "*": "ask" } },
- };
- const local: DispatchConfig = {
- permissions: { bash: { "*": "allow" } },
- };
- const merged = mergeConfigs(global, local);
- const bash = merged.permissions.bash as Record<string, string>;
- expect(bash["git status"]).toBe("allow");
- expect(bash["*"]).toBe("allow"); // local override
- });
-
- it("local string value replaces a global nested group", () => {
- const global: DispatchConfig = {
- permissions: { read: { "src/**": "allow" } },
- };
- const local: DispatchConfig = { permissions: { read: "deny" } };
- const merged = mergeConfigs(global, local);
- expect(merged.permissions.read).toBe("deny");
- });
-
- it("keeps global-only permission groups", () => {
- const global: DispatchConfig = { permissions: { read: "allow" } };
- const local: DispatchConfig = { permissions: { edit: "ask" } };
- const merged = mergeConfigs(global, local);
- expect(merged.permissions.read).toBe("allow");
- expect(merged.permissions.edit).toBe("ask");
- });
-
- it("local wins at evaluation time (findLast ordering)", () => {
- const merged = mergeConfigs(
- { permissions: { bash: { "*": "ask" } } },
- { permissions: { bash: { "*": "allow" } } },
- );
- const ruleset = configToRuleset(merged);
- expect(evaluate("bash", "anything", ruleset).action).toBe("allow");
- });
-
- // Regression: a SPECIFIC local override must not be shadowed by a more
- // GENERAL global pattern (e.g. "*") that happened to be declared lower in
- // the global block. `evaluate` uses findLast, so every local pattern must be
- // emitted AFTER all global patterns of the same group.
- it("specific local override beats a general global wildcard regardless of declaration order", () => {
- const merged = mergeConfigs(
- { permissions: { bash: { "npm test": "allow", "*": "ask" } } },
- { permissions: { bash: { "npm test": "deny" } } },
- );
- // Local "npm test" must be emitted after global "*".
- expect(Object.keys(merged.permissions.bash as object)).toEqual(["*", "npm test"]);
- const ruleset = configToRuleset(merged);
- expect(evaluate("bash", "npm test", ruleset).action).toBe("deny");
- // And the inherited global wildcard still applies to other commands.
- expect(evaluate("bash", "rm -rf /", ruleset).action).toBe("ask");
- });
-});
-
-// ─── loadConfig (filesystem integration) ─────────────────────────
-
-describe("loadConfig — global + local integration", () => {
- it("returns global config when local dispatch.toml is missing", () => {
- writeGlobal(`[lsp.biome]\ncommand = ["biome"]\nextensions = [".ts"]\n`);
- const config = loadConfig(LOCAL_DIR);
- expect(config.lsp?.biome.command).toEqual(["biome"]);
- });
-
- it("returns local config when global is missing", () => {
- writeLocal(`[permissions]\nread = "allow"\n`);
- const config = loadConfig(LOCAL_DIR);
- expect(config.permissions.read).toBe("allow");
- expect(config.lsp).toBeUndefined();
- });
-
- it("merges global LSP servers with local ones (local wins on id)", () => {
- writeGlobal(
- `[lsp.biome]\ncommand = ["global-biome"]\nextensions = [".ts"]\n\n[lsp.luau]\ncommand = ["luau-lsp"]\nextensions = [".luau"]\n`,
- );
- writeLocal(`[lsp.biome]\ncommand = ["local-biome"]\nextensions = [".ts"]\n`);
- const config = loadConfig(LOCAL_DIR);
- expect(config.lsp?.biome.command).toEqual(["local-biome"]);
- expect(config.lsp?.luau.command).toEqual(["luau-lsp"]);
- });
-
- it("a malformed global config is ignored, local still loads", () => {
- writeGlobal("not valid toml [[[");
- writeLocal(`[permissions]\nread = "allow"\n`);
- const config = loadConfig(LOCAL_DIR);
- expect(config.permissions.read).toBe("allow");
- });
-});
-
-describe("loadGlobalConfig", () => {
- it("returns empty default when the global file is missing", () => {
- expect(loadGlobalConfig()).toEqual({ permissions: {} });
- });
-
- it("loads the file at getGlobalConfigPath()", () => {
- writeGlobal(`[permissions]\nedit = "deny"\n`);
- expect(getGlobalConfigPath()).toBe(GLOBAL_PATH);
- expect(loadGlobalConfig().permissions.edit).toBe("deny");
- });
-});
diff --git a/packages/core/tests/config/watcher.test.ts b/packages/core/tests/config/watcher.test.ts
deleted file mode 100644
index 9388c8a..0000000
--- a/packages/core/tests/config/watcher.test.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { mkdirSync, rmSync, writeFileSync } from "node:fs";
-import { join } from "node:path";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import { watchDirConfig } from "../../src/config/watcher.js";
-
-const TMP = join("/tmp/opencode", "dispatch-watchdir-test");
-
-beforeEach(() => {
- mkdirSync(TMP, { recursive: true });
-});
-
-afterEach(() => {
- rmSync(TMP, { recursive: true, force: true });
-});
-
-function wait(ms: number): Promise<void> {
- return new Promise((resolve) => setTimeout(resolve, ms));
-}
-
-describe("watchDirConfig", () => {
- it("fires onChange (debounced) when <dir>/dispatch.toml changes", async () => {
- let calls = 0;
- const handle = watchDirConfig(TMP, () => {
- calls++;
- });
- // Let chokidar finish its initial scan before mutating the file.
- await wait(200);
-
- writeFileSync(join(TMP, "dispatch.toml"), `[permissions]\nread = "allow"\n`, "utf-8");
- // 300ms debounce + chokidar latency.
- await wait(700);
-
- handle.close();
- expect(calls).toBeGreaterThanOrEqual(1);
- });
-
- it("does not fire after close()", async () => {
- let calls = 0;
- const handle = watchDirConfig(TMP, () => {
- calls++;
- });
- await wait(200);
- handle.close();
-
- writeFileSync(join(TMP, "dispatch.toml"), `[permissions]\nedit = "deny"\n`, "utf-8");
- await wait(700);
-
- expect(calls).toBe(0);
- });
-});
diff --git a/packages/core/tests/credentials/wake-probe.test.ts b/packages/core/tests/credentials/wake-probe.test.ts
deleted file mode 100644
index a97a00c..0000000
--- a/packages/core/tests/credentials/wake-probe.test.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-
-// `claude.ts` transitively imports `db/index.js`, whose top-level
-// `import { Database } from "bun:sqlite"` can't resolve under vitest's Node
-// runtime. Stub the db module — `buildWakeProbeBody` never touches it.
-vi.mock("../../src/db/index.js", () => ({
- getDatabase: vi.fn(() => {
- throw new Error("db not available in this test");
- }),
-}));
-
-const { buildWakeProbeBody, selectHaikuModel } = await import("../../src/credentials/claude.js");
-
-const IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
-
-describe("buildWakeProbeBody", () => {
- it("targets the requested model with a tiny token budget", () => {
- const body = buildWakeProbeBody("claude-3-5-haiku-20241022");
- expect(body.model).toBe("claude-3-5-haiku-20241022");
- expect(body.max_tokens).toBe(16);
- });
-
- it("emits a Claude-Code-shaped system[]: billing first, identity second", () => {
- const body = buildWakeProbeBody("claude-3-5-haiku-20241022");
- expect(body.system).toHaveLength(2);
-
- // system[0] is the billing header line (no cache_control on a probe).
- expect(body.system[0]).toEqual({
- type: "text",
- text: expect.stringMatching(/^x-anthropic-billing-header: /),
- });
- expect(body.system[0]).not.toHaveProperty("cache_control");
-
- // system[1] is the VERBATIM Claude Code identity string. Anthropic
- // rejects OAuth (Pro/Max) requests whose system[] lacks this.
- expect(body.system[1]).toEqual({ type: "text", text: IDENTITY });
- });
-
- it("carries a single short user message", () => {
- const body = buildWakeProbeBody("claude-3-5-haiku-20241022");
- expect(body.messages).toEqual([{ role: "user", content: "hi" }]);
- });
-
- it("is deterministic for a given model (pure)", () => {
- const a = buildWakeProbeBody("claude-3-5-haiku-20241022");
- const b = buildWakeProbeBody("claude-3-5-haiku-20241022");
- expect(a).toEqual(b);
- });
-});
-describe("selectHaikuModel", () => {
- it("returns the id whose name contains 'haiku'", () => {
- const models = ["claude-sonnet-4-20250514", "claude-haiku-4-5-20251001"];
- expect(selectHaikuModel(models)).toBe("claude-haiku-4-5-20251001");
- });
-
- it("matches case-insensitively", () => {
- expect(selectHaikuModel(["Claude-HAIKU-Latest"])).toBe("Claude-HAIKU-Latest");
- });
-
- it("returns the FIRST match when several models contain 'haiku'", () => {
- // `/v1/models` returns newest-first, so first-match prefers the newest.
- const models = ["claude-haiku-4-5-20251001", "claude-3-5-haiku-20241022"];
- expect(selectHaikuModel(models)).toBe("claude-haiku-4-5-20251001");
- });
-
- it("returns null when no model contains 'haiku'", () => {
- expect(selectHaikuModel(["claude-sonnet-4-20250514", "claude-opus-4-20250514"])).toBeNull();
- });
-
- it("returns null for an empty list", () => {
- expect(selectHaikuModel([])).toBeNull();
- });
-});
diff --git a/packages/core/tests/db/chunks.db.test.ts b/packages/core/tests/db/chunks.db.test.ts
deleted file mode 100644
index 4f7d517..0000000
--- a/packages/core/tests/db/chunks.db.test.ts
+++ /dev/null
@@ -1,336 +0,0 @@
-import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
-import type { ChunkRowDraft, UsageData } from "../../src/types/index.js";
-
-/**
- * Internal row shape — matches the production `chunks` table columns.
- * Kept loose at the `query()` boundary to mirror bun:sqlite's dynamic
- * return type.
- */
-interface ChunkRecord {
- id: string;
- tab_id: string;
- seq: number;
- turn_id: string;
- step: number;
- role: string;
- type: string;
- data_json: string;
- created_at: number;
-}
-
-/**
- * In-memory fake of `bun:sqlite`'s Database implementing only the queries
- * `chunks.ts` actually issues. Same approach as `tabs.test.ts`: match exact
- * normalized query strings as fixed branches (no SQL parser), so a query-string
- * change fails loudly as "unsupported" instead of silently returning wrong data.
- *
- * This lets the DB-backed `getChunksForTab` / `getTotalChunkCount` /
- * `getUsageStatsForTab` logic run under vitest, where `bun:sqlite` can't load.
- */
-class FakeDatabase {
- rows: ChunkRecord[] = [];
- private idCounter = 0;
-
- query(sql: string): {
- all: (params?: Record<string, unknown>) => unknown[];
- get: (params?: Record<string, unknown>) => unknown;
- run: (params?: Record<string, unknown>) => void;
- } {
- return {
- all: (params) => this.execSelect(sql, params),
- get: (params) => this.execSelect(sql, params)[0] ?? null,
- run: (params) => {
- this.execMutation(sql, params);
- },
- };
- }
-
- /** bun:sqlite's `db.transaction(fn)` returns a callable that runs `fn`. */
- transaction(fn: () => void): () => void {
- return () => {
- fn();
- };
- }
-
- private execSelect(sql: string, params?: Record<string, unknown>): unknown[] {
- const norm = sql.replace(/\s+/g, " ").trim();
- const tabId = params?.$tabId as string | undefined;
- const forTab = this.rows.filter((r) => r.tab_id === tabId);
- const visible = forTab.filter((r) => r.type !== "usage");
-
- // appendChunks: next-seq lookup (counts ALL rows, incl. usage)
- if (norm === "SELECT COALESCE(MAX(seq), -1) as max_seq FROM chunks WHERE tab_id = $tabId") {
- const seqs = forTab.map((r) => r.seq);
- return [{ max_seq: seqs.length > 0 ? Math.max(...seqs) : -1 }];
- }
-
- // getChunksForTab — no options (usage excluded)
- if (
- norm === "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' ORDER BY seq ASC"
- ) {
- return [...visible].sort((a, b) => a.seq - b.seq);
- }
-
- // getChunksForTab — before + limit (usage excluded)
- if (
- norm ===
- "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' AND seq < $before ORDER BY seq DESC LIMIT $limit"
- ) {
- const before = params?.$before as number;
- const limit = params?.$limit as number;
- return visible
- .filter((r) => r.seq < before)
- .sort((a, b) => b.seq - a.seq)
- .slice(0, limit);
- }
-
- // getChunksForTab — before only (usage excluded)
- if (
- norm ===
- "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' AND seq < $before ORDER BY seq DESC"
- ) {
- const before = params?.$before as number;
- return visible.filter((r) => r.seq < before).sort((a, b) => b.seq - a.seq);
- }
-
- // getChunksForTab — limit only (usage excluded)
- if (
- norm ===
- "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' ORDER BY seq DESC LIMIT $limit"
- ) {
- const limit = params?.$limit as number;
- return [...visible].sort((a, b) => b.seq - a.seq).slice(0, limit);
- }
-
- // getTotalChunkCount (usage excluded)
- if (norm === "SELECT COUNT(*) as count FROM chunks WHERE tab_id = $tabId AND type != 'usage'") {
- return [{ count: visible.length }];
- }
-
- // getUsageStatsForTab: usage rows only, in seq order
- if (
- norm ===
- "SELECT data_json FROM chunks WHERE tab_id = $tabId AND type = 'usage' ORDER BY seq ASC"
- ) {
- return forTab
- .filter((r) => r.type === "usage")
- .sort((a, b) => a.seq - b.seq)
- .map((r) => ({ data_json: r.data_json }));
- }
-
- throw new Error(`FakeDatabase: unsupported SELECT: ${norm}`);
- }
-
- private execMutation(sql: string, params?: Record<string, unknown>): void {
- const norm = sql.replace(/\s+/g, " ").trim();
-
- // appendChunks: single-row insert
- if (
- norm ===
- "INSERT INTO chunks (id, tab_id, seq, turn_id, step, role, type, data_json, created_at) VALUES ($id, $tabId, $seq, $turnId, $step, $role, $type, $dataJson, $now)"
- ) {
- this.rows.push({
- id: (params?.$id as string) ?? `c${this.idCounter++}`,
- tab_id: params?.$tabId as string,
- seq: params?.$seq as number,
- turn_id: params?.$turnId as string,
- step: (params?.$step as number) ?? 0,
- role: params?.$role as string,
- type: params?.$type as string,
- data_json: params?.$dataJson as string,
- created_at: (params?.$now as number) ?? 0,
- });
- return;
- }
-
- throw new Error(`FakeDatabase: unsupported mutation: ${norm}`);
- }
-}
-
-let fakeDb: FakeDatabase;
-
-vi.mock("../../src/db/index.js", () => ({
- getDatabase: vi.fn(() => fakeDb),
-}));
-
-const { appendChunks, getChunksForTab, getTotalChunkCount, getUsageStatsForTab } = await import(
- "../../src/db/chunks.js"
-);
-
-function usageDraft(turnId: string, u: UsageData): ChunkRowDraft {
- return { turnId, step: 0, role: "assistant", type: "usage", data: u };
-}
-
-beforeAll(() => {
- fakeDb = new FakeDatabase();
-});
-
-beforeEach(() => {
- fakeDb.rows = [];
-});
-
-// ---------------------------------------------------------------------------
-// usage chunk persistence + side-channel invariants
-// ---------------------------------------------------------------------------
-describe("usage chunk rows (DB-backed)", () => {
- const TAB = "tab-usage";
-
- it("persists usage rows alongside content rows with contiguous seqs", () => {
- appendChunks(TAB, [
- { turnId: "t1", step: 0, role: "user", type: "text", data: { text: "hi" } },
- { turnId: "t1", step: 0, role: "assistant", type: "text", data: { text: "yo" } },
- usageDraft("t1", {
- inputTokens: 100,
- outputTokens: 10,
- cacheReadTokens: 0,
- cacheWriteTokens: 90,
- }),
- ]);
- // All three rows landed with contiguous seqs.
- expect(fakeDb.rows.map((r) => r.seq)).toEqual([0, 1, 2]);
- expect(fakeDb.rows.map((r) => r.type)).toEqual(["text", "text", "usage"]);
- });
-
- it("excludes usage rows from getChunksForTab (all variants)", () => {
- appendChunks(TAB, [
- { turnId: "t1", step: 0, role: "user", type: "text", data: { text: "q" } },
- usageDraft("t1", {
- inputTokens: 100,
- outputTokens: 10,
- cacheReadTokens: 0,
- cacheWriteTokens: 90,
- }),
- { turnId: "t1", step: 0, role: "assistant", type: "text", data: { text: "a" } },
- usageDraft("t1", {
- inputTokens: 200,
- outputTokens: 20,
- cacheReadTokens: 150,
- cacheWriteTokens: 0,
- }),
- ]);
-
- // no options
- const all = getChunksForTab(TAB);
- expect(all.every((r) => r.type !== "usage")).toBe(true);
- expect(all.map((r) => r.type)).toEqual(["text", "text"]);
-
- // limit only
- const limited = getChunksForTab(TAB, { limit: 10 });
- expect(limited.every((r) => r.type !== "usage")).toBe(true);
- expect(limited).toHaveLength(2);
-
- // before only — `before` is a seq cursor; usage seqs must never surface
- const before = getChunksForTab(TAB, { before: 100 });
- expect(before.every((r) => r.type !== "usage")).toBe(true);
- expect(before).toHaveLength(2);
-
- // before + limit
- const bl = getChunksForTab(TAB, { before: 100, limit: 10 });
- expect(bl.every((r) => r.type !== "usage")).toBe(true);
- expect(bl).toHaveLength(2);
- });
-
- it("excludes usage rows from getTotalChunkCount", () => {
- appendChunks(TAB, [
- { turnId: "t1", step: 0, role: "user", type: "text", data: { text: "q" } },
- { turnId: "t1", step: 0, role: "assistant", type: "text", data: { text: "a" } },
- usageDraft("t1", {
- inputTokens: 100,
- outputTokens: 10,
- cacheReadTokens: 0,
- cacheWriteTokens: 90,
- }),
- ]);
- // 3 rows total, but only 2 visible.
- expect(getTotalChunkCount(TAB)).toBe(2);
- });
-});
-
-// ---------------------------------------------------------------------------
-// getUsageStatsForTab — backend aggregate
-// ---------------------------------------------------------------------------
-describe("getUsageStatsForTab", () => {
- const TAB = "tab-agg";
-
- it("returns null when the tab has no usage rows", () => {
- appendChunks(TAB, [
- { turnId: "t1", step: 0, role: "assistant", type: "text", data: { text: "a" } },
- ]);
- expect(getUsageStatsForTab(TAB)).toBeNull();
- });
-
- it("sums cumulative tokens, counts requests, and reports the last request's split", () => {
- appendChunks(TAB, [
- usageDraft("t1", {
- inputTokens: 1000,
- outputTokens: 40,
- cacheReadTokens: 0,
- cacheWriteTokens: 900,
- }),
- usageDraft("t1", {
- inputTokens: 1200,
- outputTokens: 60,
- cacheReadTokens: 1000,
- cacheWriteTokens: 100,
- }),
- ]);
-
- const stats = getUsageStatsForTab(TAB);
- expect(stats).not.toBeNull();
- expect(stats?.requests).toBe(2);
- expect(stats?.inputTokens).toBe(2200);
- expect(stats?.outputTokens).toBe(100);
- expect(stats?.cacheReadTokens).toBe(1000);
- expect(stats?.cacheWriteTokens).toBe(1000);
- // `last` = the most recent (highest-seq) usage row.
- expect(stats?.last).toEqual({
- inputTokens: 1200,
- outputTokens: 60,
- cacheReadTokens: 1000,
- cacheWriteTokens: 100,
- });
- });
-
- it("is structurally identical to the frontend CacheStats shape (seeds directly)", () => {
- appendChunks(TAB, [
- usageDraft("t1", {
- inputTokens: 5,
- outputTokens: 1,
- cacheReadTokens: 2,
- cacheWriteTokens: 3,
- }),
- ]);
- const stats = getUsageStatsForTab(TAB);
- expect(Object.keys(stats ?? {}).sort()).toEqual(
- [
- "cacheReadTokens",
- "cacheWriteTokens",
- "inputTokens",
- "last",
- "outputTokens",
- "requests",
- ].sort(),
- );
- });
-
- it("is scoped per tab", () => {
- appendChunks("tab-a", [
- usageDraft("t1", {
- inputTokens: 10,
- outputTokens: 1,
- cacheReadTokens: 0,
- cacheWriteTokens: 0,
- }),
- ]);
- appendChunks("tab-b", [
- usageDraft("t2", {
- inputTokens: 20,
- outputTokens: 2,
- cacheReadTokens: 0,
- cacheWriteTokens: 0,
- }),
- ]);
- expect(getUsageStatsForTab("tab-a")?.inputTokens).toBe(10);
- expect(getUsageStatsForTab("tab-b")?.inputTokens).toBe(20);
- });
-});
diff --git a/packages/core/tests/db/chunks.test.ts b/packages/core/tests/db/chunks.test.ts
deleted file mode 100644
index fe54628..0000000
--- a/packages/core/tests/db/chunks.test.ts
+++ /dev/null
@@ -1,179 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { explodeTurn, explodeUserText, groupRowsToMessages } from "../../src/chunks/transform.js";
-import type { Chunk, ChunkRow, ChunkRowDraft } from "../../src/types/index.js";
-
-// These tests cover the pure explode/group transforms — the heart of the flat
-// chunk-log storage model. No DB is required.
-
-/** Promote drafts to rows with synthetic seq/id/createdAt (as appendChunks would). */
-function toRows(drafts: ChunkRowDraft[], tabId = "tab-1", startSeq = 0): ChunkRow[] {
- return drafts.map((d, i) => ({
- id: `c${i}`,
- tabId,
- seq: startSeq + i,
- turnId: d.turnId,
- step: d.step,
- role: d.role,
- type: d.type,
- data: d.data,
- createdAt: 1000 + i,
- }));
-}
-
-describe("explodeTurn", () => {
- it("splits a tool-batch into separate tool_call (assistant) and tool_result (tool) rows", () => {
- const chunks: Chunk[] = [
- { type: "thinking", text: "hmm", metadata: { anthropic: { signature: "S" } } },
- { type: "text", text: "let me read" },
- {
- type: "tool-batch",
- calls: [
- { id: "a1", name: "read_file", arguments: { path: "x" }, result: "X", isError: false },
- { id: "a2", name: "read_file", arguments: { path: "y" }, result: "Y", isError: false },
- ],
- },
- ];
- const drafts = explodeTurn("turn-1", chunks);
-
- // thinking, text, tool_call×2 (assistant), tool_result×2 (tool)
- expect(drafts.map((d) => `${d.role}/${d.type}`)).toEqual([
- "assistant/thinking",
- "assistant/text",
- "assistant/tool_call",
- "assistant/tool_call",
- "tool/tool_result",
- "tool/tool_result",
- ]);
- // All in the same step (one round-trip).
- expect(drafts.every((d) => d.step === 0)).toBe(true);
- expect(drafts.every((d) => d.turnId === "turn-1")).toBe(true);
- });
-
- it("increments step after each tool-batch (multi-step turn)", () => {
- const chunks: Chunk[] = [
- { type: "text", text: "s0" },
- { type: "tool-batch", calls: [{ id: "a", name: "t", arguments: {}, result: "r" }] },
- { type: "text", text: "s1" },
- { type: "tool-batch", calls: [{ id: "b", name: "t", arguments: {}, result: "r" }] },
- { type: "text", text: "final" },
- ];
- const drafts = explodeTurn("turn-1", chunks);
- const byStep = (s: number) => drafts.filter((d) => d.step === s).map((d) => d.type);
- expect(byStep(0)).toEqual(["text", "tool_call", "tool_result"]);
- expect(byStep(1)).toEqual(["text", "tool_call", "tool_result"]);
- expect(byStep(2)).toEqual(["text"]); // trailing final-step text, no tool-batch
- });
-
- it("omits tool_result rows for calls without a result", () => {
- const chunks: Chunk[] = [
- { type: "tool-batch", calls: [{ id: "a", name: "t", arguments: {} }] },
- ];
- const drafts = explodeTurn("turn-1", chunks);
- expect(drafts.map((d) => d.type)).toEqual(["tool_call"]);
- });
-});
-
-describe("groupRowsToMessages (round-trip)", () => {
- it("reconstructs a user message then an assistant message with a per-step tool-batch", () => {
- const rows = [
- ...toRows(explodeUserText("turn-1", "hello"), "tab-1", 0),
- ...toRows(
- explodeTurn("turn-1", [
- { type: "text", text: "reading" },
- {
- type: "tool-batch",
- calls: [
- {
- id: "a1",
- name: "read_file",
- arguments: { path: "x" },
- result: "X",
- isError: false,
- },
- ],
- },
- { type: "text", text: "done" },
- ]),
- "tab-1",
- 1,
- ),
- ];
-
- const msgs = groupRowsToMessages(rows);
- expect(msgs.map((m) => m.role)).toEqual(["user", "assistant"]);
- expect(msgs[0]?.chunks).toEqual([{ type: "text", text: "hello" }]);
-
- const a = msgs[1];
- if (!a) throw new Error("no assistant message");
- // reconstructed: text, tool-batch(step0), text(step1)
- expect(a.chunks.map((c) => c.type)).toEqual(["text", "tool-batch", "text"]);
- const batch = a.chunks.find((c) => c.type === "tool-batch");
- if (batch?.type !== "tool-batch") throw new Error("no batch");
- expect(batch.calls[0]).toMatchObject({
- id: "a1",
- name: "read_file",
- arguments: { path: "x" },
- result: "X",
- isError: false,
- });
- });
-
- it("keeps each step's tool calls in its own tool-batch chunk", () => {
- const rows = toRows(
- explodeTurn("turn-1", [
- { type: "tool-batch", calls: [{ id: "a", name: "t", arguments: {}, result: "ra" }] },
- { type: "tool-batch", calls: [{ id: "b", name: "t", arguments: {}, result: "rb" }] },
- ]),
- );
- const msgs = groupRowsToMessages(rows);
- expect(msgs).toHaveLength(1);
- const batches = msgs[0]?.chunks.filter((c) => c.type === "tool-batch") ?? [];
- expect(batches).toHaveLength(2);
- });
-
- it("round-trips a multi-step assistant turn back to its original chunk shape", () => {
- const original: Chunk[] = [
- { type: "thinking", text: "plan", metadata: { anthropic: { signature: "S" } } },
- { type: "text", text: "step0" },
- {
- type: "tool-batch",
- calls: [
- { id: "a", name: "read_file", arguments: { path: "p" }, result: "R", isError: false },
- ],
- },
- { type: "text", text: "final" },
- ];
- const rows = toRows(explodeTurn("turn-1", original));
- const msgs = groupRowsToMessages(rows);
- expect(msgs).toHaveLength(1);
- expect(msgs[0]?.chunks).toEqual(original);
- });
-
- it("tolerates an orphan tool_result whose tool_call was paged out", () => {
- const rows = toRows([
- {
- turnId: "turn-1",
- step: 0,
- role: "tool",
- type: "tool_result",
- data: { callId: "z", name: "t", result: "R", isError: false },
- },
- ]);
- const msgs = groupRowsToMessages(rows);
- expect(msgs).toHaveLength(1);
- const batch = msgs[0]?.chunks[0];
- if (batch?.type !== "tool-batch") throw new Error("no batch");
- expect(batch.calls[0]).toMatchObject({ id: "z", result: "R" });
- });
-
- it("breaks the assistant grouping on a user or system row", () => {
- const rows = [
- ...toRows(explodeUserText("t1", "q1"), "tab", 0),
- ...toRows(explodeTurn("t1", [{ type: "text", text: "a1" }]), "tab", 1),
- ...toRows(explodeUserText("t2", "q2"), "tab", 2),
- ...toRows(explodeTurn("t2", [{ type: "system", kind: "notice", text: "n" }]), "tab", 3),
- ];
- const msgs = groupRowsToMessages(rows);
- expect(msgs.map((m) => m.role)).toEqual(["user", "assistant", "user", "system"]);
- });
-});
diff --git a/packages/core/tests/db/rekey-chunks.db.test.ts b/packages/core/tests/db/rekey-chunks.db.test.ts
deleted file mode 100644
index 7cdafe3..0000000
--- a/packages/core/tests/db/rekey-chunks.db.test.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
-
-interface ChunkRecord {
- id: string;
- tab_id: string;
- seq: number;
- turn_id: string;
- step: number;
- role: string;
- type: string;
- data_json: string;
- created_at: number;
-}
-
-/**
- * Minimal in-memory fake of bun:sqlite supporting only the queries
- * `appendChunks`, `getChunksForTab`, and `rekeyChunks` issue. Mirrors the
- * approach in chunks.db.test.ts (exact normalized-string branches).
- */
-class FakeDatabase {
- rows: ChunkRecord[] = [];
-
- query(sql: string) {
- return {
- all: (params?: Record<string, unknown>) => this.execSelect(sql, params),
- get: (params?: Record<string, unknown>) => this.execSelect(sql, params)[0] ?? null,
- run: (params?: Record<string, unknown>) => this.execMutation(sql, params),
- };
- }
-
- transaction(fn: () => void): () => void {
- return () => fn();
- }
-
- private execSelect(sql: string, params?: Record<string, unknown>): unknown[] {
- const norm = sql.replace(/\s+/g, " ").trim();
- const tabId = params?.$tabId as string | undefined;
- const forTab = this.rows.filter((r) => r.tab_id === tabId);
- const visible = forTab.filter((r) => r.type !== "usage");
-
- if (norm === "SELECT COALESCE(MAX(seq), -1) as max_seq FROM chunks WHERE tab_id = $tabId") {
- const seqs = forTab.map((r) => r.seq);
- return [{ max_seq: seqs.length > 0 ? Math.max(...seqs) : -1 }];
- }
- if (
- norm === "SELECT * FROM chunks WHERE tab_id = $tabId AND type != 'usage' ORDER BY seq ASC"
- ) {
- return [...visible].sort((a, b) => a.seq - b.seq);
- }
- throw new Error(`FakeDatabase: unsupported SELECT: ${norm}`);
- }
-
- private execMutation(sql: string, params?: Record<string, unknown>): { changes: number } {
- const norm = sql.replace(/\s+/g, " ").trim();
- if (
- norm ===
- "INSERT INTO chunks (id, tab_id, seq, turn_id, step, role, type, data_json, created_at) VALUES ($id, $tabId, $seq, $turnId, $step, $role, $type, $dataJson, $now)"
- ) {
- this.rows.push({
- id: params?.$id as string,
- tab_id: params?.$tabId as string,
- seq: params?.$seq as number,
- turn_id: params?.$turnId as string,
- step: (params?.$step as number) ?? 0,
- role: params?.$role as string,
- type: params?.$type as string,
- data_json: params?.$dataJson as string,
- created_at: (params?.$now as number) ?? 0,
- });
- return { changes: 1 };
- }
- if (norm === "UPDATE chunks SET tab_id = $to WHERE tab_id = $from") {
- const from = params?.$from as string;
- const to = params?.$to as string;
- let changes = 0;
- for (const r of this.rows) {
- if (r.tab_id === from) {
- r.tab_id = to;
- changes++;
- }
- }
- return { changes };
- }
- throw new Error(`FakeDatabase: unsupported mutation: ${norm}`);
- }
-}
-
-let fakeDb: FakeDatabase;
-vi.mock("../../src/db/index.js", () => ({ getDatabase: vi.fn(() => fakeDb) }));
-
-const { appendChunks, getChunksForTab, rekeyChunks } = await import("../../src/db/chunks.js");
-
-beforeEach(() => {
- fakeDb = new FakeDatabase();
-});
-
-describe("rekeyChunks", () => {
- it("relocates all rows from one tab to another and reports the count", () => {
- appendChunks("src", [
- { turnId: "t1", step: 0, role: "user", type: "text", data: { text: "hi" } },
- { turnId: "t1", step: 0, role: "assistant", type: "text", data: { text: "yo" } },
- ]);
- expect(getChunksForTab("src")).toHaveLength(2);
-
- const moved = rekeyChunks("src", "backup");
- expect(moved).toBe(2);
- expect(getChunksForTab("src")).toHaveLength(0);
- const dst = getChunksForTab("backup");
- expect(dst).toHaveLength(2);
- // turn id + seq preserved on the destination
- expect(dst.map((r) => r.turnId)).toEqual(["t1", "t1"]);
- expect(dst.map((r) => r.seq)).toEqual([0, 1]);
- });
-
- it("returns 0 when the source tab has no rows", () => {
- expect(rekeyChunks("nope", "backup")).toBe(0);
- });
-
- it("does not touch unrelated tabs", () => {
- appendChunks("src", [
- { turnId: "t1", step: 0, role: "user", type: "text", data: { text: "a" } },
- ]);
- appendChunks("other", [
- { turnId: "t9", step: 0, role: "user", type: "text", data: { text: "b" } },
- ]);
- rekeyChunks("src", "backup");
- expect(getChunksForTab("other")).toHaveLength(1);
- });
-});
diff --git a/packages/core/tests/db/tabs.test.ts b/packages/core/tests/db/tabs.test.ts
deleted file mode 100644
index 2cd226b..0000000
--- a/packages/core/tests/db/tabs.test.ts
+++ /dev/null
@@ -1,418 +0,0 @@
-import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
-
-/**
- * Internal row shape — matches the production `tabs` table columns.
- * Kept loose (`Record`) on the `query()` boundary to mirror bun:sqlite's
- * dynamic return type.
- */
-interface TabRow {
- id: string;
- title: string;
- key_id: string | null;
- model_id: string | null;
- parent_tab_id: string | null;
- status: string;
- is_open: number;
- position: number;
- created_at: number;
- updated_at: number;
-}
-
-/**
- * In-memory fake of `bun:sqlite`'s Database that implements only the
- * queries actually issued by `tabs.ts`. This sidesteps two problems
- * the original test had:
- * 1. Vite's resolver can't load `bun:sqlite` (it's a Bun-native
- * module with no on-disk file).
- * 2. Even under `bun --bun vitest`, `vi.mock` doesn't intercept
- * module imports because Bun's loader bypasses Vite's transforms.
- *
- * By implementing the exact query strings as fixed branches we avoid
- * writing an SQL parser; if `tabs.ts` ever changes a query string,
- * tests will fail loudly with "Unsupported query" instead of
- * silently returning wrong data.
- */
-class FakeDatabase {
- rows: TabRow[] = [];
-
- /** Match production's `db.query(sql).get|all|run(params)` shape. */
- query(sql: string): {
- all: (params?: Record<string, unknown>) => unknown[];
- get: (params?: Record<string, unknown>) => unknown;
- run: (params?: Record<string, unknown>) => void;
- } {
- return {
- all: (params) => this.execSelect(sql, params),
- get: (params) => this.execSelect(sql, params)[0] ?? null,
- run: (params) => {
- this.execMutation(sql, params);
- },
- };
- }
-
- /**
- * Match Bun's `db.transaction(fn)` shape: returns a callable that runs
- * `fn` synchronously. The fake is in-memory and single-threaded, so we
- * don't emulate rollback — callers just need the wrapper to be invocable.
- */
- transaction(fn: () => void): () => void {
- return () => fn();
- }
-
- private execSelect(sql: string, params?: Record<string, unknown>): unknown[] {
- const norm = sql.replace(/\s+/g, " ").trim();
-
- // getDescendantIds: children-of query
- if (norm === "SELECT id FROM tabs WHERE parent_tab_id = $id AND is_open = 1") {
- return this.rows
- .filter((r) => r.parent_tab_id === params?.$id && r.is_open === 1)
- .map((r) => ({ id: r.id }));
- }
-
- // getTab: single-row lookup
- if (norm === "SELECT * FROM tabs WHERE id = $id") {
- const row = this.rows.find((r) => r.id === params?.$id);
- return row ? [row] : [];
- }
-
- // createTab: next-position lookup
- if (norm === "SELECT COALESCE(MAX(position), -1) as max_pos FROM tabs WHERE is_open = 1") {
- const positions = this.rows.filter((r) => r.is_open === 1).map((r) => r.position);
- const maxPos = positions.length > 0 ? Math.max(...positions) : -1;
- return [{ max_pos: maxPos }];
- }
-
- // resolveTabPrefix: open tabs whose id starts with a sanitized prefix.
- // The production query binds `$prefix` as `<sanitized>%`; emulate SQLite
- // LIKE prefix semantics here (case-insensitive, `%` = "rest of string").
- if (norm === "SELECT * FROM tabs WHERE is_open = 1 AND id LIKE $prefix ORDER BY position ASC") {
- const raw = String(params?.$prefix ?? "");
- const needle = raw.endsWith("%") ? raw.slice(0, -1) : raw;
- return this.rows
- .filter((r) => r.is_open === 1 && r.id.toLowerCase().startsWith(needle.toLowerCase()))
- .sort((a, b) => a.position - b.position);
- }
-
- // shortestUniquePrefix: all open tab ids.
- if (norm === "SELECT id FROM tabs WHERE is_open = 1") {
- return this.rows.filter((r) => r.is_open === 1).map((r) => ({ id: r.id }));
- }
-
- // listOpenTabs: every open tab ordered by position.
- if (norm === "SELECT * FROM tabs WHERE is_open = 1 ORDER BY position ASC") {
- return this.rows.filter((r) => r.is_open === 1).sort((a, b) => a.position - b.position);
- }
-
- throw new Error(`FakeDatabase: unsupported SELECT: ${norm}`);
- }
-
- private execMutation(sql: string, params?: Record<string, unknown>): void {
- const norm = sql.replace(/\s+/g, " ").trim();
-
- // createTab: full-row insert (every column named, $-bound params)
- if (
- norm ===
- "INSERT INTO tabs (id, title, key_id, model_id, parent_tab_id, status, is_open, position, created_at, updated_at) VALUES ($id, $title, $keyId, $modelId, $parentTabId, 'idle', 1, $position, $now, $now)"
- ) {
- const id = params?.$id as string;
- if (this.rows.some((r) => r.id === id)) {
- throw new Error(`UNIQUE constraint failed: tabs.id (${id})`);
- }
- this.rows.push({
- id,
- title: (params?.$title as string) ?? "",
- key_id: (params?.$keyId as string | null) ?? null,
- model_id: (params?.$modelId as string | null) ?? null,
- parent_tab_id: (params?.$parentTabId as string | null) ?? null,
- status: "idle",
- is_open: 1,
- position: (params?.$position as number) ?? 0,
- created_at: (params?.$now as number) ?? 0,
- updated_at: (params?.$now as number) ?? 0,
- });
- return;
- }
-
- // archiveTab: flip is_open to 0
- if (norm === "UPDATE tabs SET is_open = 0, updated_at = $now WHERE id = $id") {
- const row = this.rows.find((r) => r.id === params?.$id);
- if (row) {
- row.is_open = 0;
- row.updated_at = (params?.$now as number) ?? Date.now();
- }
- return;
- }
-
- // updateTabPositions: rewrite a single tab's position (run per id inside a txn)
- if (norm === "UPDATE tabs SET position = $position, updated_at = $now WHERE id = $id") {
- const row = this.rows.find((r) => r.id === params?.$id);
- if (row) {
- row.position = (params?.$position as number) ?? row.position;
- row.updated_at = (params?.$now as number) ?? Date.now();
- }
- return;
- }
-
- throw new Error(`FakeDatabase: unsupported mutation: ${norm}`);
- }
-}
-
-/**
- * Shared instance referenced by both the test setup and the
- * `vi.mock` factory below. Declared with `let` (not `const`) so the
- * factory's closure picks up the value assigned in `beforeAll`.
- */
-let fakeDb: FakeDatabase;
-
-// Mock the db module before importing `tabs.ts` so that `getDatabase()`
-// returns our in-memory fake instead of trying to open a real SQLite
-// file. Mirrors the same pattern used by `tests/agent/agent.test.ts`.
-vi.mock("../../src/db/index.js", () => ({
- getDatabase: vi.fn(() => fakeDb),
-}));
-
-// Dynamic import AFTER `vi.mock` registers (vitest hoists `vi.mock` to
-// the very top of the file, so by the time this line runs the mock is
-// active for `./index.js` resolution inside `tabs.ts`).
-const {
- archiveTab,
- createTab,
- getDescendantIds,
- getTab,
- listOpenTabs,
- resolveTabPrefix,
- shortestUniquePrefix,
- updateTabPositions,
-} = await import("../../src/db/tabs.js");
-
-beforeAll(() => {
- fakeDb = new FakeDatabase();
-});
-
-beforeEach(() => {
- fakeDb.rows = [];
-});
-
-// ---------------------------------------------------------------------------
-// getDescendantIds
-// ---------------------------------------------------------------------------
-describe("getDescendantIds", () => {
- it("returns only the id when the tab has no children", () => {
- createTab("root", "Root");
-
- const ids = getDescendantIds("root");
- expect(ids).toEqual(["root"]);
- });
-
- it("returns leaf-first order for a linear chain (root → child → grandchild)", () => {
- createTab("root", "Root");
- createTab("child", "Child", { parentTabId: "root" });
- createTab("grandchild", "Grandchild", { parentTabId: "child" });
-
- const ids = getDescendantIds("root");
- // Leaves first: grandchild, child, root
- expect(ids).toEqual(["grandchild", "child", "root"]);
- });
-
- it("returns leaf-first for a branching tree", () => {
- createTab("a", "A");
- createTab("b1", "B1", { parentTabId: "a" });
- createTab("b2", "B2", { parentTabId: "a" });
- createTab("c1", "C1", { parentTabId: "b1" });
- createTab("c2", "C2", { parentTabId: "b1" });
-
- const ids = getDescendantIds("a");
- // BFS: a, b1, b2, c1, c2 → reverse: c2, c1, b2, b1, a
- expect(ids).toEqual(["c2", "c1", "b2", "b1", "a"]);
- });
-
- it("skips archived descendants (is_open = 0)", () => {
- createTab("root", "Root");
- // Open child of root — should appear
- createTab("open-child", "Open", { parentTabId: "root" });
- // Archived child — should be skipped together with its descendants
- createTab("archived-child", "Archived", { parentTabId: "root" });
- archiveTab("archived-child");
- // Child of archived — data drift, should NOT appear (parent is archived)
- createTab("orphan", "Orphan", { parentTabId: "archived-child" });
-
- const ids = getDescendantIds("root");
- expect(ids).toEqual(["open-child", "root"]);
- expect(ids).not.toContain("archived-child");
- expect(ids).not.toContain("orphan");
- });
-
- it("handles a non-existent id gracefully", () => {
- const ids = getDescendantIds("does-not-exist");
- expect(ids).toEqual(["does-not-exist"]);
- });
-
- it("defends against accidental parent_tab_id cycles", () => {
- // Insert x first with a forward reference to y (y doesn't exist
- // yet — the schema has no foreign key enforcement). Then insert
- // y with parent_tab_id = x. Result: x.parent = y, y.parent = x.
- createTab("x", "X", { parentTabId: "y" });
- createTab("y", "Y", { parentTabId: "x" });
-
- // Must terminate — no infinite loop
- const ids = getDescendantIds("x");
- expect(ids).toContain("x");
- expect(ids).toContain("y");
- expect(ids).toHaveLength(2);
- });
-
- it("uses createTab helper and asserts is_open flag", () => {
- createTab("a1", "A1");
- createTab("b1", "B1", { parentTabId: "a1" });
- createTab("c1", "C1", { parentTabId: "b1" });
-
- // All three should be open
- expect(getTab("a1")?.isOpen).toBe(true);
- expect(getTab("b1")?.isOpen).toBe(true);
- expect(getTab("c1")?.isOpen).toBe(true);
-
- // getDescendantIds sees all three
- const ids = getDescendantIds("a1");
- expect(ids).toEqual(["c1", "b1", "a1"]);
-
- // Archive the leaf, then it should disappear
- archiveTab("c1");
- expect(getTab("c1")?.isOpen).toBe(false);
-
- const ids2 = getDescendantIds("a1");
- expect(ids2).toEqual(["b1", "a1"]);
- });
-});
-
-// ---------------------------------------------------------------------------
-// resolveTabPrefix — git-style short-handle resolution
-// ---------------------------------------------------------------------------
-describe("resolveTabPrefix", () => {
- it("returns none when the prefix is shorter than the minimum length", () => {
- createTab("abcd1234-0000-4000-8000-000000000000", "A");
- // 3 chars < MIN_TAB_PREFIX_LENGTH (4)
- expect(resolveTabPrefix("abc").status).toBe("none");
- });
-
- it("returns none when no open tab matches", () => {
- createTab("abcd1234-0000-4000-8000-000000000000", "A");
- expect(resolveTabPrefix("ffff").status).toBe("none");
- });
-
- it("resolves a unique 4-char prefix to the single matching tab", () => {
- createTab("abcd1234-0000-4000-8000-000000000000", "Alpha");
- createTab("9999aaaa-0000-4000-8000-000000000000", "Beta");
- const res = resolveTabPrefix("abcd");
- expect(res.status).toBe("ok");
- if (res.status === "ok") {
- expect(res.tab.id).toBe("abcd1234-0000-4000-8000-000000000000");
- expect(res.tab.title).toBe("Alpha");
- }
- });
-
- it("resolves the full UUID (a maximal prefix)", () => {
- createTab("abcd1234-0000-4000-8000-000000000000", "Alpha");
- const res = resolveTabPrefix("abcd1234-0000-4000-8000-000000000000");
- expect(res.status).toBe("ok");
- });
-
- it("reports ambiguity when multiple open tabs share the prefix", () => {
- createTab("abcd1111-0000-4000-8000-000000000000", "One");
- createTab("abcd2222-0000-4000-8000-000000000000", "Two");
- const res = resolveTabPrefix("abcd");
- expect(res.status).toBe("ambiguous");
- if (res.status === "ambiguous") {
- expect(res.matches).toHaveLength(2);
- expect(res.matches.map((m) => m.title).sort()).toEqual(["One", "Two"]);
- }
- });
-
- it("disambiguates when one more character is supplied", () => {
- createTab("abcd1111-0000-4000-8000-000000000000", "One");
- createTab("abcd2222-0000-4000-8000-000000000000", "Two");
- const res = resolveTabPrefix("abcd1");
- expect(res.status).toBe("ok");
- if (res.status === "ok") expect(res.tab.title).toBe("One");
- });
-
- it("matches case-insensitively (UUIDs are lowercase; LIKE is ASCII-CI)", () => {
- createTab("abcd1234-0000-4000-8000-000000000000", "Alpha");
- const res = resolveTabPrefix("ABCD");
- expect(res.status).toBe("ok");
- });
-
- it("sanitizes LIKE wildcards so they cannot broaden the match", () => {
- createTab("abcd1234-0000-4000-8000-000000000000", "Alpha");
- createTab("9999aaaa-0000-4000-8000-000000000000", "Beta");
- // `%` would match everything if not stripped; after sanitization the
- // query is effectively `abcd%` which matches only Alpha.
- const res = resolveTabPrefix("ab%d");
- // "ab%d" -> sanitized "abd" (3 chars) -> below min length -> none.
- expect(res.status).toBe("none");
- });
-
- it("excludes archived (closed) tabs from matches", () => {
- createTab("abcd1234-0000-4000-8000-000000000000", "Alpha");
- archiveTab("abcd1234-0000-4000-8000-000000000000");
- expect(resolveTabPrefix("abcd").status).toBe("none");
- });
-});
-
-// ---------------------------------------------------------------------------
-// shortestUniquePrefix — display-handle derivation
-// ---------------------------------------------------------------------------
-describe("shortestUniquePrefix", () => {
- it("returns a 4-char prefix when no other open tab collides", () => {
- createTab("abcd1234-0000-4000-8000-000000000000", "Alpha");
- expect(shortestUniquePrefix("abcd1234-0000-4000-8000-000000000000")).toBe("abcd");
- });
-
- it("grows the prefix one char at a time on a collision", () => {
- createTab("abcd1111-0000-4000-8000-000000000000", "One");
- createTab("abcd2222-0000-4000-8000-000000000000", "Two");
- // First differing char is at index 4, so a 5-char prefix is unique.
- expect(shortestUniquePrefix("abcd1111-0000-4000-8000-000000000000")).toBe("abcd1");
- expect(shortestUniquePrefix("abcd2222-0000-4000-8000-000000000000")).toBe("abcd2");
- });
-
- it("ignores closed tabs when computing uniqueness", () => {
- createTab("abcd1111-0000-4000-8000-000000000000", "One");
- createTab("abcd2222-0000-4000-8000-000000000000", "Two");
- archiveTab("abcd2222-0000-4000-8000-000000000000");
- // With Two closed, One no longer collides → back to 4 chars.
- expect(shortestUniquePrefix("abcd1111-0000-4000-8000-000000000000")).toBe("abcd");
- });
-});
-
-// ---------------------------------------------------------------------------
-// updateTabPositions — drag-and-drop reorder persistence
-// ---------------------------------------------------------------------------
-describe("updateTabPositions", () => {
- it("rewrites each tab's position to its index in the given order", () => {
- createTab("a", "A"); // position 0
- createTab("b", "B"); // position 1
- createTab("c", "C"); // position 2
-
- updateTabPositions(["c", "a", "b"]);
-
- // listOpenTabs orders by position → reflects the new order.
- expect(listOpenTabs().map((t) => t.id)).toEqual(["c", "a", "b"]);
- expect(getTab("c")?.position).toBe(0);
- expect(getTab("a")?.position).toBe(1);
- expect(getTab("b")?.position).toBe(2);
- });
-
- it("is a no-op for an empty list", () => {
- createTab("a", "A");
- createTab("b", "B");
- updateTabPositions([]);
- expect(listOpenTabs().map((t) => t.id)).toEqual(["a", "b"]);
- });
-
- it("ignores ids that don't exist without throwing", () => {
- createTab("a", "A");
- expect(() => updateTabPositions(["ghost", "a"])).not.toThrow();
- // "a" took index 1 in the requested order.
- expect(getTab("a")?.position).toBe(1);
- });
-});
diff --git a/packages/core/tests/fixture/lsp/fake-lsp-server.js b/packages/core/tests/fixture/lsp/fake-lsp-server.js
deleted file mode 100644
index d771ebd..0000000
--- a/packages/core/tests/fixture/lsp/fake-lsp-server.js
+++ /dev/null
@@ -1,195 +0,0 @@
-// Minimal JSON-RPC 2.0 LSP-like fake server over stdio, for testing the LSP
-// client without a real language server binary. Ported from opencode's
-// test/fixture/lsp/fake-lsp-server.js (trimmed to what dispatch's client and
-// manager exercise: initialize, didOpen/didChange, push + pull diagnostics).
-//
-// Test hooks (custom JSON-RPC methods the test driver can call):
-// test/get-initialize-params → returns the params sent to `initialize`
-// test/get-last-change → returns the last `didChange` params
-// test/publish-diagnostics → forwards a `publishDiagnostics` push
-// test/configure-pull-diagnostics → sets up pull-diagnostic responses
-// test/get-diagnostic-request-count→ how many pull requests were received
-
-let nextId = 1;
-let readBuffer = Buffer.alloc(0);
-let lastChange = null;
-let initializeParams = null;
-let diagnosticRequestCount = 0;
-let registeredCapability = false;
-let pullConfig = {
- registerOn: undefined,
- registrations: [],
- documentDiagnostics: [],
- workspaceDiagnostics: [],
- hasDiagnosticProvider: false,
-};
-
-function encode(message) {
- const json = JSON.stringify(message);
- const header = `Content-Length: ${Buffer.byteLength(json, "utf8")}\r\n\r\n`;
- return Buffer.concat([Buffer.from(header, "utf8"), Buffer.from(json, "utf8")]);
-}
-
-function decodeFrames(buffer) {
- const results = [];
- while (true) {
- const idx = buffer.indexOf("\r\n\r\n");
- if (idx === -1) break;
- const header = buffer.slice(0, idx).toString("utf8");
- const match = /Content-Length:\s*(\d+)/i.exec(header);
- const length = match ? parseInt(match[1], 10) : 0;
- const bodyStart = idx + 4;
- const bodyEnd = bodyStart + length;
- if (buffer.length < bodyEnd) break;
- results.push(buffer.slice(bodyStart, bodyEnd).toString("utf8"));
- buffer = buffer.slice(bodyEnd);
- }
- return { messages: results, rest: buffer };
-}
-
-function send(message) {
- process.stdout.write(encode(message));
-}
-function sendRequest(method, params) {
- const id = nextId++;
- send({ jsonrpc: "2.0", id, method, params });
- return id;
-}
-function sendResponse(id, result) {
- send({ jsonrpc: "2.0", id, result });
-}
-function sendNotification(method, params) {
- send({ jsonrpc: "2.0", method, params });
-}
-
-function maybeRegister(method) {
- if (pullConfig.registerOn !== method || registeredCapability) return;
- registeredCapability = true;
- sendRequest("client/registerCapability", {
- registrations: pullConfig.registrations.map((registration, index) => ({
- id: registration.id ?? `pull-${index}`,
- method: registration.method ?? "textDocument/diagnostic",
- registerOptions: registration.registerOptions ?? registration,
- })),
- });
-}
-
-function handle(raw) {
- let data;
- try {
- data = JSON.parse(raw);
- } catch {
- return;
- }
-
- if (data.method === "initialize") {
- initializeParams = data.params;
- sendResponse(data.id, {
- capabilities: {
- textDocumentSync: { change: 2, openClose: true },
- ...(pullConfig.hasDiagnosticProvider
- ? {
- diagnosticProvider: {
- identifier: "fake",
- interFileDependencies: false,
- workspaceDiagnostics: false,
- },
- }
- : {}),
- },
- });
- return;
- }
-
- if (data.method === "test/get-initialize-params") {
- sendResponse(data.id, initializeParams);
- return;
- }
-
- if (data.method === "initialized" || data.method === "workspace/didChangeConfiguration") {
- return;
- }
-
- if (data.method === "textDocument/didOpen") {
- maybeRegister("didOpen");
- return;
- }
-
- if (data.method === "textDocument/didChange") {
- lastChange = data.params;
- maybeRegister("didChange");
- return;
- }
-
- if (data.method === "workspace/didChangeWatchedFiles") {
- return;
- }
-
- if (data.method === "test/configure-pull-diagnostics") {
- pullConfig = {
- registerOn: data.params?.registerOn,
- registrations: data.params?.registrations ?? [],
- documentDiagnostics: data.params?.documentDiagnostics ?? [],
- workspaceDiagnostics: data.params?.workspaceDiagnostics ?? [],
- hasDiagnosticProvider: data.params?.hasDiagnosticProvider ?? false,
- };
- registeredCapability = false;
- sendResponse(data.id, null);
- return;
- }
-
- if (data.method === "test/publish-diagnostics") {
- sendNotification("textDocument/publishDiagnostics", data.params);
- sendResponse(data.id, null);
- return;
- }
-
- if (data.method === "test/get-last-change") {
- sendResponse(data.id, lastChange);
- return;
- }
-
- if (data.method === "test/get-diagnostic-request-count") {
- sendResponse(data.id, diagnosticRequestCount);
- return;
- }
-
- if (data.method === "textDocument/diagnostic") {
- diagnosticRequestCount += 1;
- sendResponse(data.id, { kind: "full", items: pullConfig.documentDiagnostics });
- return;
- }
-
- if (data.method === "workspace/diagnostic") {
- diagnosticRequestCount += 1;
- sendResponse(data.id, { items: pullConfig.workspaceDiagnostics });
- return;
- }
-
- if (data.method === "textDocument/hover") {
- sendResponse(data.id, { contents: { kind: "plaintext", value: "fake hover" } });
- return;
- }
-
- if (data.method === "textDocument/definition") {
- sendResponse(data.id, [
- {
- uri: data.params?.textDocument?.uri,
- range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
- },
- ]);
- return;
- }
-
- // Default: respond null to any other request so the client never hangs.
- if (typeof data.id !== "undefined") {
- sendResponse(data.id, null);
- }
-}
-
-process.stdin.on("data", (chunk) => {
- readBuffer = Buffer.concat([readBuffer, chunk]);
- const { messages, rest } = decodeFrames(readBuffer);
- readBuffer = rest;
- for (const message of messages) handle(message);
-});
diff --git a/packages/core/tests/llm/anthropic-oauth-transform.test.ts b/packages/core/tests/llm/anthropic-oauth-transform.test.ts
deleted file mode 100644
index a8bb156..0000000
--- a/packages/core/tests/llm/anthropic-oauth-transform.test.ts
+++ /dev/null
@@ -1,137 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { transformClaudeOAuthBody } from "../../src/llm/anthropic-oauth-transform.js";
-
-const IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
-const BILLING =
- "x-anthropic-billing-header: cc_version=2.1.112.abc; cc_entrypoint=sdk-cli; cch=12345;";
-
-interface WireBody {
- system?: Array<{ type: string; text: string; cache_control?: unknown }>;
- messages?: Array<{ role: string; content: unknown }>;
-}
-
-/**
- * Build the body shape Dispatch produces: ONE system block holding
- * `<billing>\n<identity>\n\n<systemPrompt>`, marked with cache_control by
- * `applyAnthropicCaching`.
- */
-function dispatchBody(systemPrompt: string, firstUser = "hello"): string {
- return JSON.stringify({
- model: "claude-opus-4-8",
- system: [
- {
- type: "text",
- text: `${BILLING}\n${IDENTITY}\n\n${systemPrompt}`,
- cache_control: { type: "ephemeral" },
- },
- ],
- messages: [{ role: "user", content: firstUser }],
- });
-}
-
-function parse(out: BodyInit | null | undefined): WireBody {
- return JSON.parse(out as string) as WireBody;
-}
-
-describe("transformClaudeOAuthBody", () => {
- it("isolates the billing header as system[0] WITHOUT cache_control", () => {
- const result = parse(transformClaudeOAuthBody(dispatchBody("You are Dispatch. Use tools.")));
- const sys = result.system ?? [];
- expect(sys[0]?.text).toBe(BILLING);
- expect(sys[0]?.cache_control).toBeUndefined();
- });
-
- it("keeps the identity as a separate system block and carries the cache_control there", () => {
- const result = parse(transformClaudeOAuthBody(dispatchBody("You are Dispatch. Use tools.")));
- const sys = result.system ?? [];
- expect(sys[1]?.text).toBe(IDENTITY);
- expect(sys[1]?.cache_control).toEqual({ type: "ephemeral" });
- // Only billing + identity remain in system[] — the third-party prompt was moved out.
- expect(sys).toHaveLength(2);
- });
-
- it("relocates the third-party system prompt into the first user message", () => {
- const result = parse(
- transformClaudeOAuthBody(dispatchBody("You are Dispatch. Use tools.", "do the thing")),
- );
- const sys = result.system ?? [];
- // The system prompt must NOT appear anywhere in system[].
- expect(sys.some((b) => b.text.includes("You are Dispatch"))).toBe(false);
- // It is prepended to the first user message.
- expect(result.messages?.[0]?.content).toBe("You are Dispatch. Use tools.\n\ndo the thing");
- });
-
- it("prepends a text block when the first user message uses array content", () => {
- const body = JSON.stringify({
- system: [
- {
- type: "text",
- text: `${BILLING}\n${IDENTITY}\n\nDispatch instructions here.`,
- cache_control: { type: "ephemeral" },
- },
- ],
- messages: [{ role: "user", content: [{ type: "text", text: "user text" }] }],
- });
- const result = parse(transformClaudeOAuthBody(body));
- const content = result.messages?.[0]?.content as Array<{ type: string; text: string }>;
- expect(content[0]).toEqual({ type: "text", text: "Dispatch instructions here." });
- expect(content[1]).toEqual({ type: "text", text: "user text" });
- });
-
- it("does not carry cache_control to the identity when the source had none", () => {
- const body = JSON.stringify({
- system: [{ type: "text", text: `${BILLING}\n${IDENTITY}\n\nInstr.` }],
- messages: [{ role: "user", content: "hi" }],
- });
- const result = parse(transformClaudeOAuthBody(body));
- expect(result.system?.[1]?.cache_control).toBeUndefined();
- });
-
- it("keeps the prompt as a cached system block when there is no user message", () => {
- const body = JSON.stringify({
- system: [
- {
- type: "text",
- text: `${BILLING}\n${IDENTITY}\n\nInstr only.`,
- cache_control: { type: "ephemeral" },
- },
- ],
- messages: [],
- });
- const result = parse(transformClaudeOAuthBody(body));
- const sys = result.system ?? [];
- expect(sys[0]?.text).toBe(BILLING);
- expect(sys[1]?.text).toBe(IDENTITY);
- expect(sys[2]?.text).toBe("Instr only.");
- expect(sys[2]?.cache_control).toEqual({ type: "ephemeral" });
- });
-
- it("leaves non-Claude-Code bodies (no identity string) untouched", () => {
- const body = JSON.stringify({
- system: [{ type: "text", text: "Some unrelated system prompt." }],
- messages: [{ role: "user", content: "hi" }],
- });
- // Returned unchanged (same reference string, byte-identical).
- expect(transformClaudeOAuthBody(body)).toBe(body);
- });
-
- it("returns non-string bodies unchanged", () => {
- const buf = new Uint8Array([1, 2, 3]);
- expect(transformClaudeOAuthBody(buf)).toBe(buf);
- expect(transformClaudeOAuthBody(undefined)).toBeUndefined();
- expect(transformClaudeOAuthBody(null)).toBeNull();
- });
-
- it("returns invalid JSON unchanged", () => {
- const garbage = "{not json";
- expect(transformClaudeOAuthBody(garbage)).toBe(garbage);
- });
-
- it("never emits more than the 4 cache_control breakpoints Anthropic allows", () => {
- const result = parse(transformClaudeOAuthBody(dispatchBody("Big system prompt.")));
- const all = result.system ?? [];
- const cacheBlocks = all.filter((b) => b.cache_control != null);
- // Only the identity block is marked here — well under the limit of 4.
- expect(cacheBlocks.length).toBeLessThanOrEqual(4);
- });
-});
diff --git a/packages/core/tests/llm/provider.test.ts b/packages/core/tests/llm/provider.test.ts
deleted file mode 100644
index c8c0877..0000000
--- a/packages/core/tests/llm/provider.test.ts
+++ /dev/null
@@ -1,271 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-
-// Mock @ai-sdk/anthropic to capture what options createAnthropic is called with
-const mockAnthropicInstance = vi.fn((modelId: string) => ({
- specificationVersion: "v3" as const,
- provider: "anthropic.messages",
- modelId,
- supportedUrls: {},
- doGenerate: vi.fn(),
- doStream: vi.fn(),
-}));
-const mockCreateAnthropic = vi.fn(() => mockAnthropicInstance);
-vi.mock("@ai-sdk/anthropic", () => ({
- createAnthropic: mockCreateAnthropic,
-}));
-
-// Mock @ai-sdk/openai-compatible to capture what options createOpenAICompatible
-// is called with, and what model id the returned factory is called with.
-const mockOpenAICompatibleFactory = vi.fn((modelId: string) => ({
- specificationVersion: "v3" as const,
- provider: "openai-compatible",
- modelId,
- supportedUrls: {},
- doGenerate: vi.fn(),
- doStream: vi.fn(),
-}));
-const mockCreateOpenAICompatible = vi.fn(() => mockOpenAICompatibleFactory);
-vi.mock("@ai-sdk/openai-compatible", () => ({
- createOpenAICompatible: mockCreateOpenAICompatible,
-}));
-
-const { createProvider } = await import("../../src/llm/provider.js");
-
-describe("createProvider (default OpenAI-compatible path)", () => {
- it("does not wrap the model in a middleware layer — v6 SDK handles reasoning round-trip natively", () => {
- mockCreateOpenAICompatible.mockClear();
- mockOpenAICompatibleFactory.mockClear();
-
- const model = createProvider({
- apiKey: "test-key",
- baseURL: "https://example.com/v1",
- })("deepseek-v4-pro");
-
- // The factory should have been invoked with the model id directly,
- // without going through `wrapLanguageModel`. If a middleware were
- // still in place, the returned object would carry an `_middleware`
- // property (set by our test mock pattern). The bare provider model
- // has no such property — verifying the v4-era normalizeMessages
- // middleware is gone.
- expect(mockOpenAICompatibleFactory).toHaveBeenCalledWith("deepseek-v4-pro");
- expect((model as { _middleware?: unknown })._middleware).toBeUndefined();
- });
-
- it("passes name, apiKey, baseURL to createOpenAICompatible", () => {
- mockCreateOpenAICompatible.mockClear();
-
- createProvider({
- apiKey: "zen-key",
- baseURL: "https://opencode.ai/zen/v1",
- })("deepseek-v4-pro");
-
- // We assert by property rather than full-object equality because the
- // provider also passes a `fetch:` wrapper (the debug-logger tee). The
- // load-bearing wiring is name/apiKey/baseURL; the fetch field is
- // tested separately via the wrap-fetch tests.
- expect(mockCreateOpenAICompatible).toHaveBeenCalledOnce();
- const zenArgs = mockCreateOpenAICompatible.mock.calls[0]?.[0] as Record<string, unknown>;
- expect(zenArgs.name).toBe("opencode-zen");
- expect(zenArgs.apiKey).toBe("zen-key");
- expect(zenArgs.baseURL).toBe("https://opencode.ai/zen/v1");
- expect(typeof zenArgs.fetch).toBe("function");
- });
-});
-
-describe("createClaudeOAuthProvider", () => {
- it("passes authToken (not apiKey) to createAnthropic for OAuth flow", () => {
- mockCreateAnthropic.mockClear();
-
- createProvider({
- provider: "anthropic",
- apiKey: "fallback-api-key",
- baseURL: "",
- claudeCredentials: { accessToken: "oauth-access-token" },
- })("claude-opus-4-5");
-
- expect(mockCreateAnthropic).toHaveBeenCalledOnce();
- const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as Record<string, unknown>;
- expect(callArgs.authToken).toBe("oauth-access-token");
- expect(callArgs.apiKey).toBeUndefined();
- });
-
- it("falls back to apiKey as authToken when claudeCredentials are absent", () => {
- mockCreateAnthropic.mockClear();
-
- createProvider({
- provider: "anthropic",
- apiKey: "sk-ant-api-key",
- baseURL: "",
- })("claude-opus-4-5");
-
- expect(mockCreateAnthropic).toHaveBeenCalledOnce();
- const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as Record<string, unknown>;
- expect(callArgs.authToken).toBe("sk-ant-api-key");
- expect(callArgs.apiKey).toBeUndefined();
- });
-
- it("includes required Claude CLI headers", () => {
- mockCreateAnthropic.mockClear();
-
- createProvider({
- provider: "anthropic",
- apiKey: "test-key",
- baseURL: "",
- claudeCredentials: { accessToken: "tok" },
- })("claude-opus-4-5");
-
- const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as Record<
- string,
- Record<string, string>
- >;
- expect(callArgs.headers?.["anthropic-dangerous-direct-browser-access"]).toBe("true");
- expect(callArgs.headers?.["x-app"]).toBe("cli");
- expect(callArgs.headers?.["user-agent"]).toMatch(/claude-cli/);
- });
-
- it("installs a fetch wrapper that restructures the body and stamps Claude Code session headers", async () => {
- mockCreateAnthropic.mockClear();
-
- // Capture what global fetch receives after the wrapper runs.
- const globalFetchMock = vi.fn(async () => new Response("{}", { status: 200 }));
- const prevFetch = globalThis.fetch;
- globalThis.fetch = globalFetchMock as unknown as typeof fetch;
- try {
- createProvider({
- provider: "anthropic",
- apiKey: "test-key",
- baseURL: "",
- claudeCredentials: { accessToken: "tok" },
- })("claude-opus-4-8");
-
- const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as { fetch?: typeof fetch };
- expect(typeof callArgs.fetch).toBe("function");
-
- const IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
- const BILLING =
- "x-anthropic-billing-header: cc_version=2.1.112.x; cc_entrypoint=sdk-cli; cch=abcde;";
- const body = JSON.stringify({
- system: [
- {
- type: "text",
- text: `${BILLING}\n${IDENTITY}\n\nDispatch system prompt.`,
- cache_control: { type: "ephemeral" },
- },
- ],
- messages: [{ role: "user", content: "hi there" }],
- });
-
- await callArgs.fetch?.("https://api.anthropic.com/v1/messages", {
- method: "POST",
- body,
- headers: { "content-type": "application/json" },
- });
-
- expect(globalFetchMock).toHaveBeenCalledOnce();
- const [, init] = globalFetchMock.mock.calls[0] as unknown as [unknown, RequestInit];
-
- // Body was restructured: billing isolated, third-party prompt moved to user msg.
- const sent = JSON.parse(init.body as string) as {
- system: Array<{ text: string; cache_control?: unknown }>;
- messages: Array<{ content: string }>;
- };
- expect(sent.system).toHaveLength(2);
- expect(sent.system[0]?.text).toBe(BILLING);
- expect(sent.system[0]?.cache_control).toBeUndefined();
- expect(sent.system[1]?.text).toBe(IDENTITY);
- expect(sent.messages[0]?.content).toBe("Dispatch system prompt.\n\nhi there");
-
- // Claude Code session headers were stamped.
- const headers = new Headers(init.headers);
- expect(headers.get("X-Claude-Code-Session-Id")).toBeTruthy();
- expect(headers.get("x-client-request-id")).toBeTruthy();
- } finally {
- globalThis.fetch = prevFetch;
- }
- });
-
- it("sends the anthropic-beta header so prompt-caching is honored (notes/claude-report.md Root Cause 1)", () => {
- // Without `anthropic-beta: ...,prompt-caching-scope-2026-01-05,...` the
- // Anthropic API silently ignores every `cache_control` marker we attach
- // to messages, producing a 0% cache hit rate. `@ai-sdk/anthropic` does
- // NOT inject this beta on its own — it only derives betas from tool
- // definitions — so the OAuth provider MUST set it on its config headers.
- mockCreateAnthropic.mockClear();
-
- createProvider({
- provider: "anthropic",
- apiKey: "test-key",
- baseURL: "",
- claudeCredentials: { accessToken: "tok" },
- })("claude-opus-4-5");
-
- const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as Record<
- string,
- Record<string, string>
- >;
- const betaHeader = callArgs.headers?.["anthropic-beta"];
- expect(betaHeader).toBeDefined();
- const betas = (betaHeader ?? "").split(",").map((b) => b.trim());
- // The load-bearing caching + oauth betas must be present.
- expect(betas).toContain("prompt-caching-scope-2026-01-05");
- expect(betas).toContain("oauth-2025-04-20");
- });
-
- it("uses default Anthropic baseURL when none provided", () => {
- mockCreateAnthropic.mockClear();
-
- createProvider({
- provider: "anthropic",
- apiKey: "test-key",
- baseURL: "",
- claudeCredentials: { accessToken: "tok" },
- })("claude-opus-4-5");
-
- const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as Record<string, string>;
- expect(callArgs.baseURL).toBe("https://api.anthropic.com/v1");
- });
-
- it("uses configured baseURL when provided", () => {
- mockCreateAnthropic.mockClear();
-
- createProvider({
- provider: "anthropic",
- apiKey: "test-key",
- baseURL: "https://custom.proxy.example.com/v1",
- claudeCredentials: { accessToken: "tok" },
- })("claude-opus-4-5");
-
- const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as Record<string, string>;
- expect(callArgs.baseURL).toBe("https://custom.proxy.example.com/v1");
- });
-});
-
-describe("createApiKeyAnthropicProvider", () => {
- it("passes apiKey (not authToken) to createAnthropic", () => {
- mockCreateAnthropic.mockClear();
-
- createProvider({
- provider: "opencode-anthropic",
- apiKey: "zen-api-key",
- baseURL: "",
- })("minimax-model");
-
- expect(mockCreateAnthropic).toHaveBeenCalledOnce();
- const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as Record<string, unknown>;
- expect(callArgs.apiKey).toBe("zen-api-key");
- expect(callArgs.authToken).toBeUndefined();
- });
-
- it("uses default OpenCode Zen baseURL when none provided", () => {
- mockCreateAnthropic.mockClear();
-
- createProvider({
- provider: "opencode-anthropic",
- apiKey: "zen-api-key",
- baseURL: "",
- })("minimax-model");
-
- const callArgs = mockCreateAnthropic.mock.calls[0]?.[0] as Record<string, string>;
- expect(callArgs.baseURL).toBe("https://opencode.ai/zen/go/v1");
- });
-});
diff --git a/packages/core/tests/lsp/client.test.ts b/packages/core/tests/lsp/client.test.ts
deleted file mode 100644
index 8daf8ab..0000000
--- a/packages/core/tests/lsp/client.test.ts
+++ /dev/null
@@ -1,146 +0,0 @@
-import { spawn } from "node:child_process";
-import { mkdtemp, rm, writeFile } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { dirname, join } from "node:path";
-import { fileURLToPath, pathToFileURL } from "node:url";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import type { Diagnostic } from "vscode-languageserver-types";
-import { createLspClient, type LspServerHandle } from "../../src/lsp/client.js";
-
-const FIXTURE = join(dirname(fileURLToPath(import.meta.url)), "../fixture/lsp/fake-lsp-server.js");
-
-function spawnFakeServer(): LspServerHandle {
- const proc = spawn(process.execPath, [FIXTURE], { stdio: "pipe" });
- return { process: proc as LspServerHandle["process"] };
-}
-
-const ERROR_DIAG: Diagnostic = {
- range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } },
- severity: 1,
- message: "fake type error",
- source: "Fake",
-};
-
-describe("lsp/client (fake server)", () => {
- let workDir: string;
-
- beforeEach(async () => {
- workDir = await mkdtemp(join(tmpdir(), "dispatch-lsp-"));
- });
- afterEach(async () => {
- await rm(workDir, { recursive: true, force: true });
- });
-
- it("completes the initialize handshake and forwards initializationOptions", async () => {
- const handle = spawnFakeServer();
- handle.initialization = { "luau-lsp": { platform: { type: "roblox" } } };
- const client = await createLspClient({
- serverID: "fake",
- server: handle,
- root: workDir,
- directory: workDir,
- });
-
- const params = await client.connection.sendRequest<{ initializationOptions?: unknown }>(
- "test/get-initialize-params",
- {},
- );
- expect(params.initializationOptions).toEqual({
- "luau-lsp": { platform: { type: "roblox" } },
- });
- await client.shutdown();
- });
-
- it("opens a file and receives push diagnostics", async () => {
- const handle = spawnFakeServer();
- const client = await createLspClient({
- serverID: "fake",
- server: handle,
- root: workDir,
- directory: workDir,
- });
-
- const file = join(workDir, "a.luau");
- await writeFile(file, "local x = 1\n");
- const version = await client.notifyOpen(file);
- expect(version).toBe(0);
-
- // Drive a push from the fake server, then assert it lands in the map.
- await client.connection.sendRequest("test/publish-diagnostics", {
- uri: pathToFileURL(file).href,
- diagnostics: [ERROR_DIAG],
- });
- await new Promise((r) => setTimeout(r, 50));
-
- expect(client.diagnostics.get(file)?.[0]?.message).toBe("fake type error");
- await client.shutdown();
- });
-
- it("bumps the document version on re-open (didChange)", async () => {
- const handle = spawnFakeServer();
- const client = await createLspClient({
- serverID: "fake",
- server: handle,
- root: workDir,
- directory: workDir,
- });
- const file = join(workDir, "a.luau");
- await writeFile(file, "local x = 1\n");
- expect(await client.notifyOpen(file)).toBe(0);
- await writeFile(file, "local x = 2\n");
- expect(await client.notifyOpen(file)).toBe(1);
-
- const lastChange = await client.connection.sendRequest<{ textDocument?: { version?: number } }>(
- "test/get-last-change",
- {},
- );
- expect(lastChange?.textDocument?.version).toBe(1);
- await client.shutdown();
- });
-
- it("waits for pull diagnostics when the server advertises a diagnostic provider", async () => {
- const handle = spawnFakeServer();
- const client = await createLspClient({
- serverID: "fake",
- server: handle,
- root: workDir,
- directory: workDir,
- });
- // Tell the fake server (before initialize? no — it persists) to answer
- // pull requests. We configure AFTER connect; the static provider flag is
- // read at initialize, so this test exercises the dynamic registration
- // path instead.
- await client.connection.sendRequest("test/configure-pull-diagnostics", {
- registerOn: "didOpen",
- registrations: [{ id: "d1", registerOptions: { identifier: "fake" } }],
- documentDiagnostics: [ERROR_DIAG],
- });
-
- const file = join(workDir, "a.luau");
- await writeFile(file, "bad\n");
- const version = await client.notifyOpen(file);
- await client.waitForDiagnostics({ path: file, version, mode: "document" });
-
- expect(client.diagnostics.get(file)?.some((d) => d.message === "fake type error")).toBe(true);
- await client.shutdown();
- });
-
- it("request() passes through to the server (hover)", async () => {
- const handle = spawnFakeServer();
- const client = await createLspClient({
- serverID: "fake",
- server: handle,
- root: workDir,
- directory: workDir,
- });
- const file = join(workDir, "a.luau");
- await writeFile(file, "local x = 1\n");
- await client.notifyOpen(file);
- const hover = await client.request<{ contents?: { value?: string } }>("textDocument/hover", {
- textDocument: { uri: pathToFileURL(file).href },
- position: { line: 0, character: 6 },
- });
- expect(hover?.contents?.value).toBe("fake hover");
- await client.shutdown();
- });
-});
diff --git a/packages/core/tests/lsp/diagnostic.test.ts b/packages/core/tests/lsp/diagnostic.test.ts
deleted file mode 100644
index 93ffde9..0000000
--- a/packages/core/tests/lsp/diagnostic.test.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import { describe, expect, it } from "vitest";
-import type { Diagnostic } from "vscode-languageserver-types";
-import { pretty, report } from "../../src/lsp/diagnostic.js";
-
-function diag(partial: Partial<Diagnostic> & { message: string }): Diagnostic {
- return {
- range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
- severity: 1,
- ...partial,
- };
-}
-
-describe("lsp/diagnostic", () => {
- describe("pretty", () => {
- it("renders 1-based line/col with severity label", () => {
- const out = pretty(
- diag({
- message: "Expected number",
- range: { start: { line: 4, character: 2 }, end: { line: 4, character: 8 } },
- }),
- );
- expect(out).toBe("ERROR [5:3] Expected number");
- });
-
- it("maps severities to labels", () => {
- expect(pretty(diag({ message: "w", severity: 2 }))).toMatch(/^WARN /);
- expect(pretty(diag({ message: "i", severity: 3 }))).toMatch(/^INFO /);
- expect(pretty(diag({ message: "h", severity: 4 }))).toMatch(/^HINT /);
- });
-
- it("defaults missing severity to ERROR", () => {
- expect(pretty(diag({ message: "x", severity: undefined }))).toMatch(/^ERROR /);
- });
- });
-
- describe("report", () => {
- it("returns empty string when there are no errors", () => {
- expect(report("a.luau", [])).toBe("");
- // Warnings only → still empty (errors-only).
- expect(report("a.luau", [diag({ message: "w", severity: 2 })])).toBe("");
- });
-
- it("wraps errors in a <diagnostics file> block", () => {
- const out = report("src/a.luau", [diag({ message: "boom" })]);
- expect(out).toContain('<diagnostics file="src/a.luau">');
- expect(out).toContain("ERROR [1:1] boom");
- expect(out).toContain("</diagnostics>");
- });
-
- it("filters out non-error severities", () => {
- const out = report("a.luau", [
- diag({ message: "err" }),
- diag({ message: "warn", severity: 2 }),
- ]);
- expect(out).toContain("err");
- expect(out).not.toContain("warn");
- });
-
- it("caps at 20 and notes the remainder", () => {
- const issues = Array.from({ length: 25 }, (_, i) => diag({ message: `e${i}` }));
- const out = report("a.luau", issues);
- expect(out).toContain("... and 5 more");
- expect(out).toContain("e0");
- expect(out).not.toContain("e24");
- });
- });
-});
diff --git a/packages/core/tests/lsp/luau-lsp.smoke.test.ts b/packages/core/tests/lsp/luau-lsp.smoke.test.ts
deleted file mode 100644
index 381435b..0000000
--- a/packages/core/tests/lsp/luau-lsp.smoke.test.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { execSync } from "node:child_process";
-import { mkdtemp, rm, writeFile } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import { LspManager } from "../../src/lsp/manager.js";
-import { resolveServersFromConfig } from "../../src/lsp/server.js";
-
-/**
- * Opt-in smoke test against the REAL luau-lsp binary. Skipped automatically
- * (never fails CI) when `luau-lsp` is not on PATH — mirrors opencode's
- * platform-guarded launch test. When the binary IS present, it proves the
- * end-to-end path: spawn → initialize handshake → didOpen → real diagnostics.
- */
-function hasLuauLsp(): boolean {
- try {
- execSync("luau-lsp --version", { stdio: "ignore" });
- return true;
- } catch {
- return false;
- }
-}
-
-const RUN = hasLuauLsp();
-
-describe.skipIf(!RUN)("luau-lsp real-binary smoke", () => {
- let root: string;
- let manager: LspManager;
-
- beforeEach(async () => {
- root = await mkdtemp(join(tmpdir(), "dispatch-luau-smoke-"));
- manager = new LspManager();
- });
- afterEach(async () => {
- await manager.shutdownAll();
- await rm(root, { recursive: true, force: true });
- });
-
- it("reports a real type error for a bad .luau file", async () => {
- const servers = resolveServersFromConfig({
- "luau-lsp": {
- command: ["luau-lsp", "lsp"],
- extensions: [".luau"],
- initialization: {
- "luau-lsp": {
- platform: { type: "roblox" },
- diagnostics: { strictDatamodelTypes: false },
- },
- },
- },
- });
-
- const file = join(root, "bad.luau");
- await writeFile(file, 'local x: number = "not a number"\nprint(x)\n');
-
- await manager.touchFile({ file, root, servers, mode: "document" });
- const diagnostics = manager.getDiagnostics({ root, servers, file });
- const messages = (diagnostics[file] ?? []).map((d) => d.message).join("\n");
-
- expect(messages.length).toBeGreaterThan(0);
- expect(messages.toLowerCase()).toContain("number");
- }, 60_000);
-});
diff --git a/packages/core/tests/lsp/manager.test.ts b/packages/core/tests/lsp/manager.test.ts
deleted file mode 100644
index e720413..0000000
--- a/packages/core/tests/lsp/manager.test.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-import { spawn } from "node:child_process";
-import { mkdtemp, rm, writeFile } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { dirname, join } from "node:path";
-import { fileURLToPath, pathToFileURL } from "node:url";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import type { Diagnostic } from "vscode-languageserver-types";
-import { LspManager } from "../../src/lsp/manager.js";
-import type { ResolvedLspServer } from "../../src/lsp/server.js";
-
-const FIXTURE = join(dirname(fileURLToPath(import.meta.url)), "../fixture/lsp/fake-lsp-server.js");
-
-function makeServer(id: string, extensions: string[]) {
- const counter = { count: 0 };
- const server: ResolvedLspServer = {
- id,
- extensions,
- spawn() {
- counter.count += 1;
- const proc = spawn(process.execPath, [FIXTURE], { stdio: "pipe" });
- return { process: proc as never };
- },
- };
- return { server, counter };
-}
-
-describe("lsp/manager (fake server)", () => {
- let root: string;
- let manager: LspManager;
-
- beforeEach(async () => {
- root = await mkdtemp(join(tmpdir(), "dispatch-lspmgr-"));
- manager = new LspManager();
- });
- afterEach(async () => {
- await manager.shutdownAll();
- await rm(root, { recursive: true, force: true });
- });
-
- it("hasServerForFile matches by extension", () => {
- const { server } = makeServer("fake", [".luau"]);
- expect(manager.hasServerForFile(join(root, "a.luau"), [server])).toBe(true);
- expect(manager.hasServerForFile(join(root, "a.ts"), [server])).toBe(false);
- });
-
- it("spawns lazily and reuses the client across calls", async () => {
- const { server, counter } = makeServer("fake", [".luau"]);
- const file = join(root, "a.luau");
- await writeFile(file, "local x = 1\n");
-
- const c1 = await manager.getClients({ file, root, servers: [server] });
- const c2 = await manager.getClients({ file, root, servers: [server] });
- expect(c1).toHaveLength(1);
- expect(c2).toHaveLength(1);
- expect(c1[0]).toBe(c2[0]);
- expect(counter.count).toBe(1);
- });
-
- it("does not spawn for a non-matching extension", async () => {
- const { server, counter } = makeServer("fake", [".luau"]);
- const file = join(root, "a.ts");
- await writeFile(file, "const x = 1\n");
- const clients = await manager.getClients({ file, root, servers: [server] });
- expect(clients).toHaveLength(0);
- expect(counter.count).toBe(0);
- });
-
- it("touchFile + getDiagnostics surfaces a pushed diagnostic", async () => {
- const { server } = makeServer("fake", [".luau"]);
- const file = join(root, "a.luau");
- await writeFile(file, "bad code\n");
-
- await manager.touchFile({ file, root, servers: [server] });
- const [client] = await manager.getClients({ file, root, servers: [server] });
- // Drive a push through the fake server.
- const diag: Diagnostic = {
- range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } },
- severity: 1,
- message: "manager error",
- };
- await client.connection.sendRequest("test/publish-diagnostics", {
- uri: pathToFileURL(file).href,
- diagnostics: [diag],
- });
- await new Promise((r) => setTimeout(r, 50));
-
- const result = manager.getDiagnostics({ root, servers: [server], file });
- expect(result[file]?.[0]?.message).toBe("manager error");
- });
-
- it("request() forwards to clients and flattens results", async () => {
- const { server } = makeServer("fake", [".luau"]);
- const file = join(root, "a.luau");
- await writeFile(file, "local x = 1\n");
- await manager.touchFile({ file, root, servers: [server] });
-
- const results = await manager.request({
- file,
- root,
- servers: [server],
- method: "textDocument/definition",
- params: {
- textDocument: { uri: pathToFileURL(file).href },
- position: { line: 0, character: 6 },
- },
- });
- expect(results.length).toBeGreaterThan(0);
- });
-
- it("shutdownAll clears state so the next call respawns", async () => {
- const { server, counter } = makeServer("fake", [".luau"]);
- const file = join(root, "a.luau");
- await writeFile(file, "local x = 1\n");
- await manager.getClients({ file, root, servers: [server] });
- expect(counter.count).toBe(1);
- await manager.shutdownAll();
- await manager.getClients({ file, root, servers: [server] });
- expect(counter.count).toBe(2);
- });
-});
diff --git a/packages/core/tests/lsp/server.test.ts b/packages/core/tests/lsp/server.test.ts
deleted file mode 100644
index bdaf83d..0000000
--- a/packages/core/tests/lsp/server.test.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { resolveServersFromConfig } from "../../src/lsp/server.js";
-
-describe("lsp/server resolveServersFromConfig", () => {
- it("returns [] for undefined config", () => {
- expect(resolveServersFromConfig(undefined)).toEqual([]);
- });
-
- it("resolves a server entry with id + extensions", () => {
- const servers = resolveServersFromConfig({
- "luau-lsp": { command: ["luau-lsp", "lsp"], extensions: [".luau"] },
- });
- expect(servers).toHaveLength(1);
- expect(servers[0]?.id).toBe("luau-lsp");
- expect(servers[0]?.extensions).toEqual([".luau"]);
- expect(typeof servers[0]?.spawn).toBe("function");
- });
-
- it("skips disabled entries", () => {
- const servers = resolveServersFromConfig({
- "luau-lsp": { command: ["luau-lsp", "lsp"], extensions: [".luau"], disabled: true },
- });
- expect(servers).toEqual([]);
- });
-
- it("skips entries with empty command or extensions", () => {
- const servers = resolveServersFromConfig({
- noCommand: { command: [], extensions: [".luau"] },
- noExt: { command: ["x"], extensions: [] },
- });
- expect(servers).toEqual([]);
- });
-
- it("resolves multiple servers", () => {
- const servers = resolveServersFromConfig({
- a: { command: ["a"], extensions: [".luau"] },
- b: { command: ["b"], extensions: [".lua"] },
- });
- expect(servers.map((s) => s.id).sort()).toEqual(["a", "b"]);
- });
-});
diff --git a/packages/core/tests/models/attachments.test.ts b/packages/core/tests/models/attachments.test.ts
deleted file mode 100644
index 11a9f82..0000000
--- a/packages/core/tests/models/attachments.test.ts
+++ /dev/null
@@ -1,136 +0,0 @@
-import { describe, expect, it } from "vitest";
-import {
- base64ByteLength,
- isAcceptedAttachmentMediaType,
- isImageMediaType,
- isPdfMediaType,
- MAX_ATTACHMENTS,
- MAX_IMAGE_BYTES,
- MAX_PDF_BYTES,
- MAX_TOTAL_ATTACHMENT_BYTES,
- validateUserContent,
-} from "../../src/models/attachments.js";
-import type { UserContentPart } from "../../src/types/index.js";
-
-/** A base64 string that decodes to exactly `bytes` bytes (no padding chars). */
-function base64OfBytes(bytes: number): string {
- // 4 base64 chars → 3 bytes. Use a multiple of 3 for clean (unpadded) output.
- const groups = Math.ceil(bytes / 3);
- return "A".repeat(groups * 4);
-}
-
-function imagePart(data: string, mediaType = "image/png"): UserContentPart {
- return { type: "attachment", mediaType, data };
-}
-
-describe("media-type predicates", () => {
- it("classifies image types", () => {
- expect(isImageMediaType("image/png")).toBe(true);
- expect(isImageMediaType("image/jpeg")).toBe(true);
- expect(isImageMediaType("image/webp")).toBe(true);
- expect(isImageMediaType("image/gif")).toBe(true);
- expect(isImageMediaType("application/pdf")).toBe(false);
- expect(isImageMediaType("image/svg+xml")).toBe(false);
- });
-
- it("classifies pdf + accepted types", () => {
- expect(isPdfMediaType("application/pdf")).toBe(true);
- expect(isPdfMediaType("image/png")).toBe(false);
- expect(isAcceptedAttachmentMediaType("image/gif")).toBe(true);
- expect(isAcceptedAttachmentMediaType("application/pdf")).toBe(true);
- expect(isAcceptedAttachmentMediaType("text/plain")).toBe(false);
- });
-});
-
-describe("base64ByteLength", () => {
- it("computes decoded length without padding", () => {
- // "AAAA" → 3 bytes.
- expect(base64ByteLength("AAAA")).toBe(3);
- });
-
- it("accounts for padding", () => {
- // "QQ==" → 1 byte ("A").
- expect(base64ByteLength("QQ==")).toBe(1);
- // "QUI=" → 2 bytes ("AB").
- expect(base64ByteLength("QUI=")).toBe(2);
- });
-
- it("tolerates a data: URI prefix and whitespace", () => {
- expect(base64ByteLength("data:image/png;base64,AAAA")).toBe(3);
- expect(base64ByteLength("AA\nAA")).toBe(3);
- });
-
- it("returns 0 for empty input", () => {
- expect(base64ByteLength("")).toBe(0);
- expect(base64ByteLength(" ")).toBe(0);
- });
-});
-
-describe("validateUserContent", () => {
- it("accepts a small image and ignores text parts", () => {
- const content: UserContentPart[] = [
- { type: "text", text: "hi" },
- imagePart(base64OfBytes(1024)),
- ];
- expect(validateUserContent(content)).toEqual({ ok: true, errors: [] });
- });
-
- it("accepts an empty / text-only content list", () => {
- expect(validateUserContent([]).ok).toBe(true);
- expect(validateUserContent([{ type: "text", text: "no files" }]).ok).toBe(true);
- });
-
- it("rejects an unsupported media type", () => {
- const res = validateUserContent([imagePart(base64OfBytes(10), "image/svg+xml")]);
- expect(res.ok).toBe(false);
- expect(res.errors[0]).toMatchObject({ code: "unsupported-type", mediaType: "image/svg+xml" });
- });
-
- it("rejects an oversized image but allows a PDF of the same size", () => {
- const big = base64OfBytes(MAX_IMAGE_BYTES + 3);
- const imgRes = validateUserContent([imagePart(big, "image/png")]);
- expect(imgRes.ok).toBe(false);
- expect(imgRes.errors.some((e) => e.code === "image-too-large")).toBe(true);
-
- // Same byte size as a PDF is fine (PDF limit is much higher).
- const pdfRes = validateUserContent([imagePart(big, "application/pdf")]);
- expect(pdfRes.ok).toBe(true);
- });
-
- it("rejects an oversized PDF", () => {
- const res = validateUserContent([
- imagePart(base64OfBytes(MAX_PDF_BYTES + 3), "application/pdf"),
- ]);
- expect(res.ok).toBe(false);
- expect(res.errors.some((e) => e.code === "pdf-too-large")).toBe(true);
- });
-
- it("rejects an empty attachment payload", () => {
- const res = validateUserContent([imagePart("", "image/png")]);
- expect(res.ok).toBe(false);
- expect(res.errors.some((e) => e.code === "empty")).toBe(true);
- });
-
- it("rejects too many attachments", () => {
- const content: UserContentPart[] = Array.from({ length: MAX_ATTACHMENTS + 1 }, () =>
- imagePart(base64OfBytes(8)),
- );
- const res = validateUserContent(content);
- expect(res.ok).toBe(false);
- expect(res.errors.some((e) => e.code === "too-many")).toBe(true);
- });
-
- it("rejects when the total payload exceeds the request ceiling", () => {
- // Several individually-legal PDFs that together exceed the total cap.
- const each = Math.floor(MAX_TOTAL_ATTACHMENT_BYTES / 3);
- const content: UserContentPart[] = [
- imagePart(base64OfBytes(each), "application/pdf"),
- imagePart(base64OfBytes(each), "application/pdf"),
- imagePart(base64OfBytes(each), "application/pdf"),
- imagePart(base64OfBytes(each), "application/pdf"),
- ];
- const res = validateUserContent(content);
- expect(res.ok).toBe(false);
- expect(res.errors.some((e) => e.code === "total-too-large")).toBe(true);
- });
-});
diff --git a/packages/core/tests/models/catalog.test.ts b/packages/core/tests/models/catalog.test.ts
deleted file mode 100644
index f4bddc2..0000000
--- a/packages/core/tests/models/catalog.test.ts
+++ /dev/null
@@ -1,227 +0,0 @@
-import { existsSync, rmSync, utimesSync, writeFileSync } from "node:fs";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import {
- __resetCatalogCacheForTests,
- getModelsCatalog,
- resolveContextLimit,
- resolveModelCapabilities,
-} from "../../src/models/catalog.js";
-
-const CACHE_PATH = "/tmp/dispatch/models-dev.json";
-
-// A trimmed models.dev-shaped catalog covering the providers we support.
-const CATALOG = {
- anthropic: {
- id: "anthropic",
- models: {
- "claude-sonnet-4-5": {
- limit: { context: 200000, output: 64000 },
- modalities: { input: ["text", "image", "pdf"], output: ["text"] },
- },
- "claude-sonnet-4-6": {
- limit: { context: 1000000, output: 64000 },
- modalities: { input: ["text", "image", "pdf"], output: ["text"] },
- },
- // A text-only model: definitively no image/pdf input.
- "text-only-model": {
- limit: { context: 100000, output: 8192 },
- modalities: { input: ["text"], output: ["text"] },
- },
- // An entry predating the modalities field → capability unknown.
- "legacy-model": { limit: { context: 100000, output: 8192 } },
- },
- },
- opencode: {
- id: "opencode",
- models: {
- "glm-4-6": {
- limit: { context: 131072, output: 8192 },
- modalities: { input: ["text", "image"], output: ["text"] },
- },
- },
- },
-};
-
-function mockFetchOnce(catalog: unknown, ok = true, status = 200) {
- const fn = vi.fn(() =>
- Promise.resolve({
- ok,
- status,
- text: () => Promise.resolve(JSON.stringify(catalog)),
- } as Response),
- );
- vi.stubGlobal("fetch", fn);
- return fn;
-}
-
-beforeEach(() => {
- __resetCatalogCacheForTests();
- if (existsSync(CACHE_PATH)) rmSync(CACHE_PATH);
- delete process.env.DISPATCH_DISABLE_MODELS_FETCH;
-});
-
-afterEach(() => {
- vi.unstubAllGlobals();
- if (existsSync(CACHE_PATH)) rmSync(CACHE_PATH);
-});
-
-describe("resolveContextLimit", () => {
- it("resolves a known anthropic model to its context window", async () => {
- mockFetchOnce(CATALOG);
- expect(await resolveContextLimit("anthropic", "claude-sonnet-4-5")).toBe(200000);
- expect(await resolveContextLimit("anthropic", "claude-sonnet-4-6")).toBe(1000000);
- });
-
- it("maps opencode-anthropic to the anthropic catalog, then opencode fallback", async () => {
- mockFetchOnce(CATALOG);
- // Present in the anthropic catalog.
- expect(await resolveContextLimit("opencode-anthropic", "claude-sonnet-4-5")).toBe(200000);
- // Absent in anthropic, found in the opencode gateway catalog.
- expect(await resolveContextLimit("opencode-anthropic", "glm-4-6")).toBe(131072);
- });
-
- it("returns null for an unknown model id", async () => {
- mockFetchOnce(CATALOG);
- expect(await resolveContextLimit("anthropic", "no-such-model")).toBeNull();
- });
-
- it("returns null for an unsupported provider (no network needed)", async () => {
- const fetchFn = mockFetchOnce(CATALOG);
- expect(await resolveContextLimit("google", "gemini-2.5-pro")).toBeNull();
- expect(await resolveContextLimit("anthropic", "")).toBeNull();
- expect(fetchFn).not.toHaveBeenCalled();
- });
-
- it("returns null when the model has no positive context limit", async () => {
- mockFetchOnce({
- anthropic: { id: "anthropic", models: { broken: { limit: { context: 0 } } } },
- });
- expect(await resolveContextLimit("anthropic", "broken")).toBeNull();
- });
-
- it("does not throw on a malformed provider entry missing `models`", async () => {
- // A provider object without a `models` map must degrade to null, not crash.
- mockFetchOnce({ anthropic: { id: "anthropic" } });
- expect(await resolveContextLimit("anthropic", "claude-sonnet-4-5")).toBeNull();
- });
-
- it("does not throw when limit/context fields are absent", async () => {
- mockFetchOnce({ anthropic: { id: "anthropic", models: { m: {} } } });
- expect(await resolveContextLimit("anthropic", "m")).toBeNull();
- });
-});
-
-describe("getModelsCatalog caching", () => {
- it("fetches once and serves the in-process memo on subsequent calls", async () => {
- const fetchFn = mockFetchOnce(CATALOG);
- await resolveContextLimit("anthropic", "claude-sonnet-4-5");
- await resolveContextLimit("anthropic", "claude-sonnet-4-6");
- await getModelsCatalog();
- expect(fetchFn).toHaveBeenCalledTimes(1);
- });
-
- it("reuses a fresh disk cache without re-fetching across processes", async () => {
- // Simulate another process having written a fresh cache.
- writeFileSync(CACHE_PATH, JSON.stringify(CATALOG), "utf-8");
- const fetchFn = vi.fn(() => Promise.reject(new Error("network should not be hit")));
- vi.stubGlobal("fetch", fetchFn);
- expect(await resolveContextLimit("anthropic", "claude-sonnet-4-5")).toBe(200000);
- expect(fetchFn).not.toHaveBeenCalled();
- });
-
- it("falls back to a STALE disk cache when the network fails", async () => {
- writeFileSync(CACHE_PATH, JSON.stringify(CATALOG), "utf-8");
- // Age the cache well past the TTL so the fetch path is taken.
- const old = Date.now() / 1000 - 3600;
- utimesSync(CACHE_PATH, old, old);
- const fetchFn = vi.fn(() => Promise.reject(new Error("offline")));
- vi.stubGlobal("fetch", fetchFn);
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
-
- expect(await resolveContextLimit("anthropic", "claude-sonnet-4-5")).toBe(200000);
- expect(fetchFn).toHaveBeenCalledTimes(1);
- warn.mockRestore();
- });
-
- it("returns null when fetch fails and no cache exists", async () => {
- const fetchFn = vi.fn(() => Promise.reject(new Error("offline")));
- vi.stubGlobal("fetch", fetchFn);
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
- expect(await resolveContextLimit("anthropic", "claude-sonnet-4-5")).toBeNull();
- warn.mockRestore();
- });
-
- it("does not hit the network when DISPATCH_DISABLE_MODELS_FETCH is set", async () => {
- process.env.DISPATCH_DISABLE_MODELS_FETCH = "1";
- const fetchFn = vi.fn(() => Promise.reject(new Error("should not fetch")));
- vi.stubGlobal("fetch", fetchFn);
- expect(await resolveContextLimit("anthropic", "claude-sonnet-4-5")).toBeNull();
- expect(fetchFn).not.toHaveBeenCalled();
- });
-
- it("memoizes the fallback after a failed fetch so it does not re-hit the network", async () => {
- const fetchFn = vi.fn(() => Promise.reject(new Error("offline")));
- vi.stubGlobal("fetch", fetchFn);
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
-
- // First lookup triggers the (failing) fetch.
- expect(await resolveContextLimit("anthropic", "claude-sonnet-4-5")).toBeNull();
- // Subsequent lookups within the penalty window must NOT re-fetch.
- expect(await resolveContextLimit("anthropic", "claude-sonnet-4-6")).toBeNull();
- await getModelsCatalog();
- expect(fetchFn).toHaveBeenCalledTimes(1);
- warn.mockRestore();
- });
-});
-
-describe("resolveModelCapabilities", () => {
- it("reports image + pdf for a vision model", async () => {
- mockFetchOnce(CATALOG);
- expect(await resolveModelCapabilities("anthropic", "claude-sonnet-4-5")).toEqual({
- image: true,
- pdf: true,
- });
- });
-
- it("reports image-only for a model whose modalities omit pdf", async () => {
- mockFetchOnce(CATALOG);
- // glm-4-6 lists image but not pdf (resolved via the opencode fallback).
- expect(await resolveModelCapabilities("opencode-anthropic", "glm-4-6")).toEqual({
- image: true,
- pdf: false,
- });
- });
-
- it("reports a definitive no for a text-only model", async () => {
- mockFetchOnce(CATALOG);
- expect(await resolveModelCapabilities("anthropic", "text-only-model")).toEqual({
- image: false,
- pdf: false,
- });
- });
-
- it("returns null (unknown) for an entry without modalities", async () => {
- mockFetchOnce(CATALOG);
- expect(await resolveModelCapabilities("anthropic", "legacy-model")).toBeNull();
- });
-
- it("returns null (unknown) for an unknown model id", async () => {
- mockFetchOnce(CATALOG);
- expect(await resolveModelCapabilities("anthropic", "no-such-model")).toBeNull();
- });
-
- it("returns null for an unsupported provider without hitting the network", async () => {
- const fetchFn = mockFetchOnce(CATALOG);
- expect(await resolveModelCapabilities("google", "gemini-2.5-pro")).toBeNull();
- expect(await resolveModelCapabilities("anthropic", "")).toBeNull();
- expect(fetchFn).not.toHaveBeenCalled();
- });
-
- it("returns null (unknown) when the catalog is offline with no cache", async () => {
- const fetchFn = vi.fn(() => Promise.reject(new Error("offline")));
- vi.stubGlobal("fetch", fetchFn);
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
- expect(await resolveModelCapabilities("anthropic", "claude-sonnet-4-5")).toBeNull();
- warn.mockRestore();
- });
-});
diff --git a/packages/core/tests/notifications/config.test.ts b/packages/core/tests/notifications/config.test.ts
deleted file mode 100644
index 71dc00c..0000000
--- a/packages/core/tests/notifications/config.test.ts
+++ /dev/null
@@ -1,158 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
-
-// In-memory fake for the settings table — mounted before the module under
-// test is imported (vi.mock is hoisted).
-const fakeSettings = new Map<string, string>();
-
-vi.mock("../../src/db/settings.js", () => ({
- getSetting: vi.fn((key: string) => fakeSettings.get(key) ?? null),
- setSetting: vi.fn((key: string, value: string) => {
- fakeSettings.set(key, value);
- }),
- deleteSetting: vi.fn((key: string) => {
- fakeSettings.delete(key);
- }),
-}));
-
-const {
- clearNtfyConfig,
- defaultNtfyConfig,
- loadNtfyConfig,
- normalizeNtfyConfig,
- NTFY_CONFIG_KEY,
- redactNtfyConfig,
- saveNtfyConfig,
-} = await import("../../src/notifications/config.js");
-
-describe("defaultNtfyConfig", () => {
- it("disables notifications and ships sane per-event defaults", () => {
- const cfg = defaultNtfyConfig();
- expect(cfg.enabled).toBe(false);
- expect(cfg.topic).toBe("");
- expect(cfg.authToken).toBe("");
- expect(cfg.events["turn-completed"]).toBe(true);
- expect(cfg.events["turn-error"]).toBe(true);
- expect(cfg.events["permission-required"]).toBe(true);
- expect(cfg.events["agent-spawned"]).toBe(false);
- expect(cfg.notifySubagents).toBe(false);
- });
-});
-
-describe("normalizeNtfyConfig", () => {
- it("returns defaults for non-object input", () => {
- expect(normalizeNtfyConfig(null)).toEqual(defaultNtfyConfig());
- expect(normalizeNtfyConfig(undefined)).toEqual(defaultNtfyConfig());
- expect(normalizeNtfyConfig(42)).toEqual(defaultNtfyConfig());
- });
-
- it("fills in missing event toggles with defaults (newly-added types default OFF)", () => {
- const normalized = normalizeNtfyConfig({
- enabled: true,
- topic: "https://ntfy.sh/x",
- events: { "turn-completed": false },
- });
- expect(normalized.events["turn-completed"]).toBe(false);
- // Defaults preserved for fields the persisted blob doesn't have.
- expect(normalized.events["turn-error"]).toBe(true);
- expect(normalized.events["agent-spawned"]).toBe(false);
- });
-
- it("ignores extraneous fields and wrong-typed values", () => {
- const normalized = normalizeNtfyConfig({
- enabled: "yes", // wrong type ⇒ default
- topic: 42, // wrong type ⇒ default
- authToken: null, // wrong type ⇒ default
- events: { "turn-completed": "no", bogus: true },
- extra: "ignored",
- });
- expect(normalized.enabled).toBe(false);
- expect(normalized.topic).toBe("");
- expect(normalized.authToken).toBe("");
- expect(normalized.events["turn-completed"]).toBe(true); // default kept
- expect((normalized.events as Record<string, boolean>).bogus).toBeUndefined();
- });
-});
-
-describe("normalizeNtfyConfig — notifySubagents", () => {
- it("defaults notifySubagents to false when absent", () => {
- const normalized = normalizeNtfyConfig({
- enabled: true,
- topic: "https://ntfy.sh/x",
- });
- expect(normalized.notifySubagents).toBe(false);
- });
-
- it("respects an explicit notifySubagents=true", () => {
- const normalized = normalizeNtfyConfig({
- enabled: true,
- topic: "https://ntfy.sh/x",
- notifySubagents: true,
- });
- expect(normalized.notifySubagents).toBe(true);
- });
-
- it("falls back to default when notifySubagents is wrong-typed", () => {
- const normalized = normalizeNtfyConfig({
- enabled: true,
- topic: "https://ntfy.sh/x",
- notifySubagents: "yes" as unknown,
- });
- expect(normalized.notifySubagents).toBe(false);
- });
-});
-
-describe("load/save round-trip", () => {
- beforeEach(() => {
- fakeSettings.clear();
- });
-
- it("returns defaults when nothing is persisted", () => {
- expect(loadNtfyConfig()).toEqual(defaultNtfyConfig());
- });
-
- it("round-trips a complete config", () => {
- const cfg = {
- enabled: true,
- topic: "https://ntfy.sh/team",
- authToken: "tk_abc",
- events: {
- "turn-completed": false,
- "turn-error": true,
- "permission-required": true,
- "agent-spawned": true,
- },
- notifySubagents: true,
- } as const;
- saveNtfyConfig({ ...cfg });
- const loaded = loadNtfyConfig();
- expect(loaded).toEqual(cfg);
- // Persisted as a JSON string under the documented key.
- expect(fakeSettings.has(NTFY_CONFIG_KEY)).toBe(true);
- });
-
- it("returns defaults when stored JSON is corrupt", () => {
- fakeSettings.set(NTFY_CONFIG_KEY, "{ not json");
- expect(loadNtfyConfig()).toEqual(defaultNtfyConfig());
- });
-
- it("clearNtfyConfig removes the persisted entry", () => {
- saveNtfyConfig({ ...defaultNtfyConfig(), enabled: true, topic: "https://ntfy.sh/x" });
- expect(fakeSettings.has(NTFY_CONFIG_KEY)).toBe(true);
- clearNtfyConfig();
- expect(fakeSettings.has(NTFY_CONFIG_KEY)).toBe(false);
- });
-});
-
-describe("redactNtfyConfig", () => {
- it("strips authToken and surfaces a hasAuthToken flag", () => {
- const cfg = { ...defaultNtfyConfig(), authToken: "tk_secret" };
- const redacted = redactNtfyConfig(cfg);
- expect(redacted.authToken).toBe("");
- expect(redacted.hasAuthToken).toBe(true);
- });
-
- it("hasAuthToken is false for blank tokens", () => {
- expect(redactNtfyConfig({ ...defaultNtfyConfig(), authToken: "" }).hasAuthToken).toBe(false);
- expect(redactNtfyConfig({ ...defaultNtfyConfig(), authToken: " " }).hasAuthToken).toBe(false);
- });
-});
diff --git a/packages/core/tests/notifications/dispatcher.test.ts b/packages/core/tests/notifications/dispatcher.test.ts
deleted file mode 100644
index c2faba6..0000000
--- a/packages/core/tests/notifications/dispatcher.test.ts
+++ /dev/null
@@ -1,461 +0,0 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import type { NotificationEvent, NtfyConfig } from "../../src/notifications/types.js";
-
-// The dispatcher imports `loadNtfyConfig` from config.ts, which transitively
-// pulls in `db/index.js` (bun:sqlite). Stub the DB so vitest under Node can
-// load this file. All tests inject `loadConfig` explicitly, so the real
-// settings table is never read.
-vi.mock("../../src/db/index.js", () => ({
- getDatabase: vi.fn(() => ({
- query: () => ({ get: () => null, run: () => {} }),
- run: () => {},
- })),
-}));
-
-const { NotificationDispatcher } = await import("../../src/notifications/dispatcher.js");
-
-function makeConfig(overrides: Partial<NtfyConfig> = {}): NtfyConfig {
- return {
- enabled: true,
- topic: "test-topic",
- authToken: "",
- events: {
- "turn-completed": true,
- "turn-error": true,
- "permission-required": true,
- "agent-spawned": true,
- },
- // Default to true in the test config so existing tests (which never
- // configure a getTabParentId lookup) keep firing for tab-1 / tab-2 / etc.
- // Tests of the new subagent gating override this explicitly.
- notifySubagents: true,
- ...overrides,
- };
-}
-
-interface FakeAgentSource {
- onEvent(
- listener: (event: { type: string; tabId: string; [k: string]: unknown }) => void,
- ): () => void;
- emit(event: { type: string; tabId: string; [k: string]: unknown }): void;
-}
-
-function makeAgentSource(): FakeAgentSource {
- let l: ((event: { type: string; tabId: string; [k: string]: unknown }) => void) | null = null;
- return {
- onEvent(listener) {
- l = listener;
- return () => {
- l = null;
- };
- },
- emit(event) {
- l?.(event);
- },
- };
-}
-
-interface FakePermissionSource {
- onPromptAdded(
- listener: (prompt: { id: string; permission: string; description: string }) => void,
- ): () => void;
- emit(prompt: { id: string; permission: string; description: string }): void;
-}
-
-function makePermissionSource(): FakePermissionSource {
- let l: ((prompt: { id: string; permission: string; description: string }) => void) | null = null;
- return {
- onPromptAdded(listener) {
- l = listener;
- return () => {
- l = null;
- };
- },
- emit(p) {
- l?.(p);
- },
- };
-}
-
-// Microtask flush so the dispatcher's `void Promise.resolve(...).catch(...)`
-// has a chance to settle before assertions.
-async function flush(): Promise<void> {
- await Promise.resolve();
- await Promise.resolve();
-}
-
-describe("NotificationDispatcher.notify", () => {
- let warnSpy: ReturnType<typeof vi.spyOn>;
- beforeEach(() => {
- warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
- });
- afterEach(() => {
- warnSpy.mockRestore();
- });
-
- it("does not send when master switch is disabled", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const d = new NotificationDispatcher({
- loadConfig: () => makeConfig({ enabled: false }),
- send,
- });
- d.notify({ type: "turn-completed", title: "x", message: "y" });
- await flush();
- expect(send).not.toHaveBeenCalled();
- });
-
- it("does not send when per-event-type toggle is off", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const d = new NotificationDispatcher({
- loadConfig: () =>
- makeConfig({
- events: {
- "turn-completed": false,
- "turn-error": true,
- "permission-required": true,
- "agent-spawned": false,
- },
- }),
- send,
- });
- d.notify({ type: "turn-completed", title: "x", message: "y" });
- await flush();
- expect(send).not.toHaveBeenCalled();
- });
-
- it("sends when enabled and toggle is on", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
- d.notify({ type: "turn-completed", title: "x", message: "y" });
- await flush();
- expect(send).toHaveBeenCalledTimes(1);
- });
-
- it("does not throw or block when the transport rejects", async () => {
- const send = vi.fn(async () => {
- throw new Error("boom");
- });
- const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
- expect(() => d.notify({ type: "turn-completed", title: "x", message: "y" })).not.toThrow();
- await flush();
- expect(send).toHaveBeenCalledTimes(1);
- expect(warnSpy).toHaveBeenCalled();
- });
-
- it("dedupes events with the same dedupeKey within the window", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const d = new NotificationDispatcher({
- loadConfig: () => makeConfig(),
- send,
- dedupeWindowMs: 1000,
- });
- const event: NotificationEvent = {
- type: "permission-required",
- title: "p",
- message: "p",
- dedupeKey: "permission:42",
- };
- d.notify(event);
- d.notify(event);
- d.notify(event);
- await flush();
- expect(send).toHaveBeenCalledTimes(1);
- });
-
- it("does not dedupe events without a dedupeKey", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
- d.notify({ type: "turn-completed", title: "x", message: "y" });
- d.notify({ type: "turn-completed", title: "x", message: "y" });
- await flush();
- expect(send).toHaveBeenCalledTimes(2);
- });
-});
-
-describe("NotificationDispatcher.attachToAgentManager", () => {
- let warnSpy: ReturnType<typeof vi.spyOn>;
- beforeEach(() => {
- warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
- });
- afterEach(() => {
- warnSpy.mockRestore();
- });
-
- it("maps `done` → turn-completed (with tab title in the body)", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makeAgentSource();
- const d = new NotificationDispatcher({
- loadConfig: () => makeConfig(),
- send,
- getTabTitle: (id) => (id === "tab-1" ? "My chat" : null),
- });
- d.attachToAgentManager(source);
- source.emit({ type: "done", tabId: "tab-1", message: { role: "assistant", chunks: [] } });
- await flush();
- expect(send).toHaveBeenCalledTimes(1);
- const event = send.mock.calls[0][1] as NotificationEvent;
- expect(event.type).toBe("turn-completed");
- expect(event.title).toContain("My chat");
- expect(event.tabId).toBe("tab-1");
- });
-
- it("maps `error` → turn-error and includes the error text", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makeAgentSource();
- const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
- d.attachToAgentManager(source);
- source.emit({ type: "error", tabId: "tab-1", error: "Rate limit", statusCode: 429 });
- await flush();
- expect(send).toHaveBeenCalledTimes(1);
- const event = send.mock.calls[0][1] as NotificationEvent;
- expect(event.type).toBe("turn-error");
- expect(event.message).toContain("Rate limit");
- expect(event.message).toContain("429");
- });
-
- it("ignores `status` events (would spam every transition)", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makeAgentSource();
- const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
- d.attachToAgentManager(source);
- source.emit({ type: "status", tabId: "tab-1", status: "running" });
- source.emit({ type: "status", tabId: "tab-1", status: "idle" });
- await flush();
- expect(send).not.toHaveBeenCalled();
- });
-
- it("maps `tab-created` to agent-spawned only for top-level user agents (parentTabId=null AND agentSlug set)", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makeAgentSource();
- const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
- d.attachToAgentManager(source);
-
- // Manual "new tab" with no agent slug ⇒ no notification.
- source.emit({
- type: "tab-created",
- tabId: "tab-1",
- id: "tab-1",
- title: "New Tab",
- parentTabId: null,
- agentSlug: null,
- });
- // Subagent (has a parent) ⇒ no notification.
- source.emit({
- type: "tab-created",
- tabId: "tab-2",
- id: "tab-2",
- title: "Subagent",
- parentTabId: "tab-1",
- agentSlug: "researcher",
- });
- // Top-level user agent ⇒ notify.
- source.emit({
- type: "tab-created",
- tabId: "tab-3",
- id: "tab-3",
- title: "Refactor auth code",
- parentTabId: null,
- agentSlug: "engineer",
- });
- await flush();
- expect(send).toHaveBeenCalledTimes(1);
- const event = send.mock.calls[0][1] as NotificationEvent;
- expect(event.type).toBe("agent-spawned");
- expect(event.message).toBe("Refactor auth code");
- expect(event.title).toContain("engineer");
- });
-
- it("respects the per-event-type toggle (turn-completed off ⇒ silent)", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makeAgentSource();
- const d = new NotificationDispatcher({
- loadConfig: () =>
- makeConfig({
- events: {
- "turn-completed": false,
- "turn-error": true,
- "permission-required": true,
- "agent-spawned": false,
- },
- }),
- send,
- });
- d.attachToAgentManager(source);
- source.emit({ type: "done", tabId: "tab-1", message: { role: "assistant", chunks: [] } });
- await flush();
- expect(send).not.toHaveBeenCalled();
- });
-});
-
-describe("NotificationDispatcher.attachToPermissionManager", () => {
- let warnSpy: ReturnType<typeof vi.spyOn>;
- beforeEach(() => {
- warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
- });
- afterEach(() => {
- warnSpy.mockRestore();
- });
-
- it("notifies once per unique prompt id (dedupes re-emits)", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makePermissionSource();
- const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
- d.attachToPermissionManager(source);
-
- source.emit({ id: "1", permission: "bash", description: "Run git status" });
- source.emit({ id: "1", permission: "bash", description: "Run git status" });
- source.emit({ id: "2", permission: "read", description: "Read /etc/hosts" });
- await flush();
- expect(send).toHaveBeenCalledTimes(2);
- const events = send.mock.calls.map((c) => c[1] as NotificationEvent);
- expect(events.map((e) => e.type)).toEqual(["permission-required", "permission-required"]);
- expect(events.every((e) => e.dedupeKey?.startsWith("permission:"))).toBe(true);
- });
-});
-
-describe("NotificationDispatcher.dispose", () => {
- it("releases attached subscriptions", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makeAgentSource();
- const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
- d.attachToAgentManager(source);
- d.dispose();
- source.emit({ type: "done", tabId: "tab-1", message: { role: "assistant", chunks: [] } });
- await flush();
- expect(send).not.toHaveBeenCalled();
- });
-});
-
-describe("NotificationDispatcher subagent suppression (notifySubagents flag)", () => {
- let warnSpy: ReturnType<typeof vi.spyOn>;
- beforeEach(() => {
- warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
- });
- afterEach(() => {
- warnSpy.mockRestore();
- });
-
- const parents = new Map<string, string | null>([
- ["top-level", null],
- ["subagent", "top-level"],
- ]);
- const getTabParentId = (id: string): string | null | undefined => parents.get(id);
-
- it("suppresses turn-completed from subagent tabs when notifySubagents=false (default)", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makeAgentSource();
- const d = new NotificationDispatcher({
- loadConfig: () => makeConfig({ notifySubagents: false }),
- send,
- getTabParentId,
- });
- d.attachToAgentManager(source);
-
- source.emit({ type: "done", tabId: "subagent", message: { role: "assistant", chunks: [] } });
- source.emit({ type: "done", tabId: "top-level", message: { role: "assistant", chunks: [] } });
- await flush();
-
- expect(send).toHaveBeenCalledTimes(1);
- expect((send.mock.calls[0][1] as NotificationEvent).tabId).toBe("top-level");
- });
-
- it("suppresses turn-error from subagent tabs when notifySubagents=false", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makeAgentSource();
- const d = new NotificationDispatcher({
- loadConfig: () => makeConfig({ notifySubagents: false }),
- send,
- getTabParentId,
- });
- d.attachToAgentManager(source);
-
- source.emit({ type: "error", tabId: "subagent", error: "boom" });
- source.emit({ type: "error", tabId: "top-level", error: "boom" });
- await flush();
-
- expect(send).toHaveBeenCalledTimes(1);
- expect((send.mock.calls[0][1] as NotificationEvent).tabId).toBe("top-level");
- });
-
- it("still notifies subagents when notifySubagents=true", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makeAgentSource();
- const d = new NotificationDispatcher({
- loadConfig: () => makeConfig({ notifySubagents: true }),
- send,
- getTabParentId,
- });
- d.attachToAgentManager(source);
-
- source.emit({ type: "done", tabId: "subagent", message: { role: "assistant", chunks: [] } });
- source.emit({ type: "done", tabId: "top-level", message: { role: "assistant", chunks: [] } });
- await flush();
-
- expect(send).toHaveBeenCalledTimes(2);
- });
-
- it("does NOT gate permission-required (subagents must still get human input)", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const psource = makePermissionSource();
- const d = new NotificationDispatcher({
- loadConfig: () => makeConfig({ notifySubagents: false }),
- send,
- getTabParentId,
- });
- d.attachToPermissionManager(psource);
-
- psource.emit({ id: "p1", permission: "bash", description: "git status" });
- await flush();
-
- expect(send).toHaveBeenCalledTimes(1);
- expect((send.mock.calls[0][1] as NotificationEvent).type).toBe("permission-required");
- });
-
- it("falls back to notifying when getTabParentId is not provided (treat as top-level)", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makeAgentSource();
- const d = new NotificationDispatcher({
- loadConfig: () => makeConfig({ notifySubagents: false }),
- send,
- // intentionally NO getTabParentId
- });
- d.attachToAgentManager(source);
-
- source.emit({ type: "done", tabId: "anything", message: { role: "assistant", chunks: [] } });
- await flush();
-
- // Without a lookup, the dispatcher can't prove this is a subagent; it
- // must err on the side of notifying so legitimate top-level events
- // aren't silently dropped.
- expect(send).toHaveBeenCalledTimes(1);
- });
-
- it("falls back to notifying when getTabParentId throws or returns undefined", async () => {
- const send = vi.fn(async () => ({ ok: true }));
- const source = makeAgentSource();
- const d = new NotificationDispatcher({
- loadConfig: () => makeConfig({ notifySubagents: false }),
- send,
- getTabParentId: () => {
- throw new Error("db unavailable");
- },
- });
- d.attachToAgentManager(source);
-
- source.emit({ type: "done", tabId: "x", message: { role: "assistant", chunks: [] } });
- await flush();
- expect(send).toHaveBeenCalledTimes(1);
-
- const send2 = vi.fn(async () => ({ ok: true }));
- const source2 = makeAgentSource();
- const d2 = new NotificationDispatcher({
- loadConfig: () => makeConfig({ notifySubagents: false }),
- send: send2,
- getTabParentId: () => undefined,
- });
- d2.attachToAgentManager(source2);
- source2.emit({ type: "done", tabId: "x", message: { role: "assistant", chunks: [] } });
- await flush();
- expect(send2).toHaveBeenCalledTimes(1);
- });
-});
diff --git a/packages/core/tests/notifications/ntfy.test.ts b/packages/core/tests/notifications/ntfy.test.ts
deleted file mode 100644
index 5f14a60..0000000
--- a/packages/core/tests/notifications/ntfy.test.ts
+++ /dev/null
@@ -1,204 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import { buildNtfyUrl, NTFY_BASE_URL, sendNtfy } from "../../src/notifications/ntfy.js";
-import type { NotificationEvent, NtfyConfig } from "../../src/notifications/types.js";
-
-function makeConfig(overrides: Partial<NtfyConfig> = {}): NtfyConfig {
- return {
- enabled: true,
- topic: "my-topic",
- authToken: "",
- events: {
- "turn-completed": true,
- "turn-error": true,
- "permission-required": true,
- "agent-spawned": true,
- },
- notifySubagents: false,
- ...overrides,
- };
-}
-
-function makeEvent(overrides: Partial<NotificationEvent> = {}): NotificationEvent {
- return {
- type: "turn-completed",
- title: "Done",
- message: "all good",
- ...overrides,
- };
-}
-
-function makeFetch(
- response: Partial<{ ok: boolean; status: number; statusText: string; body: string }> = {},
-) {
- const fetchImpl = vi.fn(async () => ({
- ok: response.ok ?? true,
- status: response.status ?? 200,
- statusText: response.statusText ?? "OK",
- text: async () => response.body ?? "",
- }));
- return fetchImpl;
-}
-
-describe("buildNtfyUrl", () => {
- it("prefixes the public ntfy.sh host", () => {
- expect(buildNtfyUrl("my-topic")).toBe(`${NTFY_BASE_URL}/my-topic`);
- });
-
- it("trims surrounding whitespace", () => {
- expect(buildNtfyUrl(" hello ")).toBe(`${NTFY_BASE_URL}/hello`);
- });
-
- it("URL-encodes the topic so any string yields a valid URL", () => {
- // Spaces, slashes, unicode — all preserved as encoded bytes; the ntfy
- // server is the final authority on what it accepts.
- expect(buildNtfyUrl("has space")).toBe(`${NTFY_BASE_URL}/has%20space`);
- expect(buildNtfyUrl("a/b")).toBe(`${NTFY_BASE_URL}/a%2Fb`);
- expect(buildNtfyUrl("日本語")).toBe(`${NTFY_BASE_URL}/${encodeURIComponent("日本語")}`);
- });
-});
-
-describe("sendNtfy", () => {
- it("POSTs to https://ntfy.sh/<topic> with Title/Priority/Tags/Content-Type headers and body", async () => {
- const fetchImpl = makeFetch();
- const result = await sendNtfy(
- makeConfig(),
- makeEvent({ title: "Hello", message: "World", tags: ["bell"], priority: 4 }),
- fetchImpl,
- );
- expect(result.ok).toBe(true);
- expect(fetchImpl).toHaveBeenCalledTimes(1);
- const [url, init] = fetchImpl.mock.calls[0];
- expect(url).toBe(`${NTFY_BASE_URL}/my-topic`);
- expect(init.method).toBe("POST");
- expect(init.headers.Title).toBe("Hello");
- expect(init.headers.Priority).toBe("4");
- expect(init.headers.Tags).toBe("bell");
- expect(init.headers["Content-Type"]).toMatch(/text\/plain/);
- expect(init.body).toBe("World");
- });
-
- it("accepts arbitrary topic strings without a client-side pattern check", async () => {
- const fetchImpl = makeFetch();
- // Things the old validator would have rejected — dots, spaces, unicode,
- // a single-word "any topic". All should POST and let ntfy decide.
- await sendNtfy(makeConfig({ topic: "release.notes" }), makeEvent(), fetchImpl);
- await sendNtfy(makeConfig({ topic: "with space" }), makeEvent(), fetchImpl);
- await sendNtfy(makeConfig({ topic: "Any Topic Whatsoever" }), makeEvent(), fetchImpl);
- await sendNtfy(makeConfig({ topic: "日本語" }), makeEvent(), fetchImpl);
- expect(fetchImpl).toHaveBeenCalledTimes(4);
- expect(fetchImpl.mock.calls[0][0]).toBe(`${NTFY_BASE_URL}/release.notes`);
- expect(fetchImpl.mock.calls[1][0]).toBe(`${NTFY_BASE_URL}/with%20space`);
- expect(fetchImpl.mock.calls[2][0]).toBe(`${NTFY_BASE_URL}/Any%20Topic%20Whatsoever`);
- expect(fetchImpl.mock.calls[3][0]).toBe(`${NTFY_BASE_URL}/${encodeURIComponent("日本語")}`);
- });
-
- it("uses per-event-type defaults for priority and tags", async () => {
- const fetchImpl = makeFetch();
- await sendNtfy(makeConfig(), makeEvent({ type: "turn-error" }), fetchImpl);
- const init = fetchImpl.mock.calls[0][1];
- expect(init.headers.Priority).toBe("4"); // NTFY_DEFAULT_PRIORITIES["turn-error"]
- expect(init.headers.Tags).toBe("rotating_light");
- });
-
- it("attaches Authorization header with Bearer prefix when authToken is a bare token", async () => {
- const fetchImpl = makeFetch();
- await sendNtfy(makeConfig({ authToken: "tk_secret " }), makeEvent(), fetchImpl);
- const init = fetchImpl.mock.calls[0][1];
- expect(init.headers.Authorization).toBe("Bearer tk_secret");
- });
-
- it("passes a pre-prefixed Authorization value (Basic, custom schemes) through verbatim", async () => {
- const fetchImpl = makeFetch();
- await sendNtfy(makeConfig({ authToken: "Basic dXNlcjpwYXNz" }), makeEvent(), fetchImpl);
- expect(fetchImpl.mock.calls[0][1].headers.Authorization).toBe("Basic dXNlcjpwYXNz");
-
- const fetchImpl2 = makeFetch();
- await sendNtfy(makeConfig({ authToken: "Bearer already_prefixed" }), makeEvent(), fetchImpl2);
- expect(fetchImpl2.mock.calls[0][1].headers.Authorization).toBe("Bearer already_prefixed");
- });
-
- it("omits Authorization when authToken is blank", async () => {
- const fetchImpl = makeFetch();
- await sendNtfy(makeConfig({ authToken: " " }), makeEvent(), fetchImpl);
- const init = fetchImpl.mock.calls[0][1];
- expect(init.headers.Authorization).toBeUndefined();
- });
-
- it("attaches Click header when clickUrl is set", async () => {
- const fetchImpl = makeFetch();
- await sendNtfy(makeConfig(), makeEvent({ clickUrl: "https://example.com/tab/abc" }), fetchImpl);
- const init = fetchImpl.mock.calls[0][1];
- expect(init.headers.Click).toBe("https://example.com/tab/abc");
- });
-
- it("sanitizes Click header (CR/LF injection guard)", async () => {
- const fetchImpl = makeFetch();
- await sendNtfy(
- makeConfig(),
- makeEvent({ clickUrl: "https://example.com/\r\nInjected: yes" }),
- fetchImpl,
- );
- const v = fetchImpl.mock.calls[0][1].headers.Click;
- expect(v).not.toContain("\n");
- expect(v).not.toContain("\r");
- });
-
- it("appends short tab tag when tabId is set", async () => {
- const fetchImpl = makeFetch();
- await sendNtfy(
- makeConfig(),
- makeEvent({ tabId: "abcdef0123456789", tags: ["bell"] }),
- fetchImpl,
- );
- const init = fetchImpl.mock.calls[0][1];
- expect(init.headers.Tags).toBe("bell,tab-abcdef01");
- });
-
- it("strips CR/LF/control chars from header values (injection guard)", async () => {
- const fetchImpl = makeFetch();
- await sendNtfy(makeConfig(), makeEvent({ title: "line1\r\nInjected: yes" }), fetchImpl);
- const init = fetchImpl.mock.calls[0][1];
- expect(init.headers.Title).not.toContain("\n");
- expect(init.headers.Title).not.toContain("\r");
- expect(init.headers.Title).toBe("line1 Injected: yes");
- });
-
- it("returns ok:false when notifications are disabled", async () => {
- const fetchImpl = makeFetch();
- const result = await sendNtfy(makeConfig({ enabled: false }), makeEvent(), fetchImpl);
- expect(result.ok).toBe(false);
- expect(result.error).toMatch(/disabled/);
- expect(fetchImpl).not.toHaveBeenCalled();
- });
-
- it("returns ok:false when topic is empty / whitespace, without calling fetch", async () => {
- const fetchImpl = makeFetch();
- const empty = await sendNtfy(makeConfig({ topic: "" }), makeEvent(), fetchImpl);
- expect(empty.ok).toBe(false);
- expect(empty.error).toMatch(/required/i);
-
- const ws = await sendNtfy(makeConfig({ topic: " " }), makeEvent(), fetchImpl);
- expect(ws.ok).toBe(false);
- expect(ws.error).toMatch(/required/i);
-
- expect(fetchImpl).not.toHaveBeenCalled();
- });
-
- it("returns ok:false with status on non-2xx response", async () => {
- const fetchImpl = makeFetch({ ok: false, status: 403, statusText: "Forbidden", body: "nope" });
- const result = await sendNtfy(makeConfig(), makeEvent(), fetchImpl);
- expect(result.ok).toBe(false);
- expect(result.status).toBe(403);
- expect(result.error).toMatch(/403/);
- expect(result.error).toMatch(/nope/);
- });
-
- it("returns ok:false with error message on fetch throwing", async () => {
- const fetchImpl = vi.fn(async () => {
- throw new Error("ECONNREFUSED");
- });
- const result = await sendNtfy(makeConfig(), makeEvent(), fetchImpl);
- expect(result.ok).toBe(false);
- expect(result.error).toMatch(/ECONNREFUSED/);
- });
-});
diff --git a/packages/core/tests/permission/evaluate.test.ts b/packages/core/tests/permission/evaluate.test.ts
deleted file mode 100644
index c8f4541..0000000
--- a/packages/core/tests/permission/evaluate.test.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { evaluate } from "../../src/permission/evaluate.js";
-import type { Ruleset } from "../../src/permission/index.js";
-
-describe("evaluate", () => {
- it("returns default ask when no rules match", () => {
- const result = evaluate("bash", "ls -la");
- expect(result.action).toBe("ask");
- expect(result.permission).toBe("bash");
- expect(result.pattern).toBe("ls -la");
- });
-
- it("returns allow when matching rule is allow", () => {
- const rules: Ruleset = [{ permission: "bash", pattern: "ls *", action: "allow" }];
- const result = evaluate("bash", "ls -la", rules);
- expect(result.action).toBe("allow");
- });
-
- it("returns deny when matching rule is deny", () => {
- const rules: Ruleset = [{ permission: "bash", pattern: "rm *", action: "deny" }];
- const result = evaluate("bash", "rm -rf /", rules);
- expect(result.action).toBe("deny");
- });
-
- it("last-match-wins: later deny overrides earlier allow", () => {
- const rules: Ruleset = [
- { permission: "bash", pattern: "*", action: "allow" },
- { permission: "bash", pattern: "rm *", action: "deny" },
- ];
- const result = evaluate("bash", "rm -rf /", rules);
- expect(result.action).toBe("deny");
- });
-
- it("last-match-wins: later allow overrides earlier deny", () => {
- const rules: Ruleset = [
- { permission: "bash", pattern: "rm *", action: "deny" },
- { permission: "bash", pattern: "*", action: "allow" },
- ];
- const result = evaluate("bash", "rm -rf /", rules);
- expect(result.action).toBe("allow");
- });
-
- it("matches permission wildcard", () => {
- const rules: Ruleset = [{ permission: "*", pattern: "*", action: "allow" }];
- const result = evaluate("read", "anything", rules);
- expect(result.action).toBe("allow");
- });
-
- it("multiple rulesets are concatenated, last match wins across rulesets", () => {
- const baseRules: Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }];
- const overrideRules: Ruleset = [{ permission: "bash", pattern: "git *", action: "allow" }];
- const result = evaluate("bash", "git status", baseRules, overrideRules);
- expect(result.action).toBe("allow");
- });
-
- it("second ruleset can deny what first ruleset allows", () => {
- const baseRules: Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }];
- const overrideRules: Ruleset = [{ permission: "bash", pattern: "rm *", action: "deny" }];
- const result = evaluate("bash", "rm -rf /", baseRules, overrideRules);
- expect(result.action).toBe("deny");
- });
-
- it("non-matching permission returns default ask", () => {
- const rules: Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }];
- const result = evaluate("read", "/some/path", rules);
- expect(result.action).toBe("ask");
- });
-});
diff --git a/packages/core/tests/permission/service.test.ts b/packages/core/tests/permission/service.test.ts
deleted file mode 100644
index d1b39d9..0000000
--- a/packages/core/tests/permission/service.test.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-import { describe, expect, it } from "vitest";
-import type { PermissionRequest, Ruleset } from "../../src/permission/index.js";
-import { PermissionService } from "../../src/permission/service.js";
-
-function makeRequest(overrides: Partial<PermissionRequest> = {}): PermissionRequest {
- return {
- permission: "bash",
- patterns: ["git *"],
- always: ["git status"],
- description: "Run git status",
- metadata: {},
- ...overrides,
- };
-}
-
-describe("PermissionService", () => {
- it("resolves immediately with 'once' when rule is allow", async () => {
- const svc = new PermissionService();
- const rules: Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }];
- const reply = await svc.ask(makeRequest(), [rules]);
- expect(reply).toBe("once");
- });
-
- it("rejects immediately when rule is deny", async () => {
- const svc = new PermissionService();
- const rules: Ruleset = [{ permission: "bash", pattern: "*", action: "deny" }];
- await expect(svc.ask(makeRequest(), [rules])).rejects.toThrow("Permission denied");
- });
-
- it("creates pending request when rule is ask", () => {
- const svc = new PermissionService();
- svc.ask(makeRequest(), []);
- expect(svc.getPending()).toHaveLength(1);
- });
-
- it("reply 'once' resolves the specific pending request", async () => {
- const svc = new PermissionService();
- const promise = svc.ask(makeRequest(), []);
- const pending = svc.getPending();
- expect(pending).toHaveLength(1);
- svc.reply(pending[0].id, "once");
- const result = await promise;
- expect(result).toBe("once");
- expect(svc.getPending()).toHaveLength(0);
- });
-
- it("reply 'always' adds approved rules and resolves", async () => {
- const svc = new PermissionService();
- const promise = svc.ask(makeRequest({ patterns: ["git *"] }), []);
- const pending = svc.getPending();
- svc.reply(pending[0].id, "always");
- const result = await promise;
- expect(result).toBe("always");
-
- // Now the same permission should be immediately allowed
- const reply2 = await svc.ask(makeRequest({ always: ["git commit"] }), []);
- expect(reply2).toBe("once");
- });
-
- it("reply 'reject' rejects all pending requests (cascade)", async () => {
- const svc = new PermissionService();
- const p1 = svc.ask(makeRequest(), []);
- const p2 = svc.ask(makeRequest({ permission: "read" }), []);
-
- const pending = svc.getPending();
- expect(pending).toHaveLength(2);
-
- // Reject using the first id — should cascade to all
- svc.reply(pending[0].id, "reject");
-
- await expect(p1).rejects.toThrow("Permission rejected");
- await expect(p2).rejects.toThrow("Permission rejected");
- expect(svc.getPending()).toHaveLength(0);
- });
-
- it("approved rules override config rulesets", async () => {
- const svc = new PermissionService();
- svc.approve([{ permission: "bash", pattern: "git *", action: "allow" }]);
-
- // Config says deny, but approved says allow — approved wins (last)
- const configRules: Ruleset = [{ permission: "bash", pattern: "*", action: "deny" }];
- const reply = await svc.ask(makeRequest({ always: ["git status"] }), [configRules]);
- expect(reply).toBe("once");
- });
-
- it("getPending returns all pending requests with id and request", () => {
- const svc = new PermissionService();
- const req = makeRequest();
- svc.ask(req, []);
- const pending = svc.getPending();
- expect(pending).toHaveLength(1);
- expect(pending[0].id).toBeDefined();
- expect(pending[0].request.permission).toBe("bash");
- });
-});
diff --git a/packages/core/tests/permission/wildcard.test.ts b/packages/core/tests/permission/wildcard.test.ts
deleted file mode 100644
index 8fa30a7..0000000
--- a/packages/core/tests/permission/wildcard.test.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { Wildcard } from "../../src/permission/wildcard.js";
-
-describe("Wildcard.match", () => {
- it("matches exact string", () => {
- expect(Wildcard.match("bash", "bash")).toBe(true);
- expect(Wildcard.match("bash", "read")).toBe(false);
- });
-
- it("matches * wildcard (any characters)", () => {
- expect(Wildcard.match("*", "bash")).toBe(true);
- expect(Wildcard.match("*", "anything")).toBe(true);
- expect(Wildcard.match("ba*", "bash")).toBe(true);
- expect(Wildcard.match("ba*", "ba")).toBe(true);
- expect(Wildcard.match("ba*", "read")).toBe(false);
- });
-
- it("matches ? wildcard (single character)", () => {
- expect(Wildcard.match("ba?h", "bash")).toBe(true);
- expect(Wildcard.match("ba?h", "bath")).toBe(true);
- expect(Wildcard.match("ba?h", "baXXh")).toBe(false);
- expect(Wildcard.match("?", "a")).toBe(true);
- expect(Wildcard.match("?", "ab")).toBe(false);
- });
-
- it("matches nested * patterns with path-like strings", () => {
- expect(Wildcard.match("/home/*", "/home/user")).toBe(true);
- expect(Wildcard.match("/home/*/file.txt", "/home/user/file.txt")).toBe(true);
- expect(Wildcard.match("/home/*/file.txt", "/home/user/subdir/file.txt")).toBe(true);
- expect(Wildcard.match("/home/*/file.txt", "/tmp/user/file.txt")).toBe(false);
- });
-
- it("escapes regex special characters in pattern", () => {
- expect(Wildcard.match("git add .", "git add .")).toBe(true);
- expect(Wildcard.match("git add .", "git add X")).toBe(false);
- expect(Wildcard.match("foo(bar)", "foo(bar)")).toBe(true);
- expect(Wildcard.match("foo(bar)", "fooXbar")).toBe(false);
- });
-
- it("is case-sensitive", () => {
- expect(Wildcard.match("Bash", "bash")).toBe(false);
- expect(Wildcard.match("BASH", "BASH")).toBe(true);
- });
-
- it("handles empty pattern and value", () => {
- expect(Wildcard.match("", "")).toBe(true);
- expect(Wildcard.match("", "x")).toBe(false);
- expect(Wildcard.match("*", "")).toBe(true);
- });
-});
diff --git a/packages/core/tests/tools/bash-arity.test.ts b/packages/core/tests/tools/bash-arity.test.ts
deleted file mode 100644
index a01a6a5..0000000
--- a/packages/core/tests/tools/bash-arity.test.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { prefix } from "../../src/tools/bash-arity.js";
-
-describe("BashArity.prefix", () => {
- it("returns arity-2 prefix for known command 'git'", () => {
- expect(prefix(["git", "checkout", "main"])).toEqual(["git", "checkout"]);
- });
-
- it("returns arity-3 prefix for npm", () => {
- expect(prefix(["npm", "run", "dev"])).toEqual(["npm", "run", "dev"]);
- });
-
- it("returns arity-2 prefix for bun", () => {
- expect(prefix(["bun", "install", "--frozen-lockfile"])).toEqual(["bun", "install"]);
- });
-
- it("returns just the command for unknown command", () => {
- expect(prefix(["unknowncmd", "arg1", "arg2"])).toEqual(["unknowncmd"]);
- });
-
- it("returns empty array for empty tokens", () => {
- expect(prefix([])).toEqual([]);
- });
-
- it("handles single token for unknown command", () => {
- expect(prefix(["ls"])).toEqual(["ls"]);
- });
-
- it("handles git with fewer tokens than arity", () => {
- expect(prefix(["git"])).toEqual(["git"]);
- });
-
- it("handles case-insensitive matching", () => {
- expect(prefix(["GIT", "checkout", "main"])).toEqual(["GIT", "checkout"]);
- });
-});
diff --git a/packages/core/tests/tools/key-usage.test.ts b/packages/core/tests/tools/key-usage.test.ts
deleted file mode 100644
index 643e30e..0000000
--- a/packages/core/tests/tools/key-usage.test.ts
+++ /dev/null
@@ -1,317 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-
-// The tool imports `getAccountUsageWithSource` from `claude.ts`, which
-// transitively imports `db/index.js` (top-level `import { Database } from
-// "bun:sqlite"`) — unresolvable under vitest's Node runtime. These tests inject
-// stub fetchers and never hit the real fetchers/DB, so stubbing the db module
-// is enough to let the import chain resolve.
-vi.mock("../../src/db/index.js", () => ({
- getDatabase: vi.fn(() => {
- throw new Error("db not available in this test");
- }),
-}));
-
-import type { ClaudeAccount, ClaudeUsageResult } from "../../src/credentials/claude.js";
-import type { OpencodeUsageReport } from "../../src/credentials/opencode.js";
-import {
- createKeyUsageTool,
- formatKeyUsage,
- type KeyUsageCallbacks,
-} from "../../src/tools/key-usage.js";
-import type { KeyDefinition, KeyState } from "../../src/types/index.js";
-
-// ─── Builders ─────────────────────────────────────────────────
-
-function keyState(
- def: Partial<KeyDefinition> & { id: string; provider: string },
- overrides: Partial<Omit<KeyState, "definition">> = {},
-): KeyState {
- return {
- definition: { base_url: "https://example.test", ...def },
- status: "active",
- ...overrides,
- };
-}
-
-function account(id: string, source = `/creds/${id}.json`): ClaudeAccount {
- return {
- id,
- label: id,
- source,
- credentials: { accessToken: "tok", refreshToken: "ref", expiresAt: Date.now() + 3_600_000 },
- };
-}
-
-/** Build the tool with explicit stub fetchers — no network, no DB. */
-function buildTool(opts: {
- keys: KeyState[];
- accounts?: ClaudeAccount[];
- anthropic?: (a: ClaudeAccount) => Promise<ClaudeUsageResult | null>;
- opencode?: (keyId: string) => Promise<OpencodeUsageReport | null>;
-}) {
- const callbacks: KeyUsageCallbacks = {
- listKeys: () => opts.keys,
- listClaudeAccounts: () => opts.accounts ?? [],
- fetchAnthropicUsage: opts.anthropic ?? (async () => null),
- fetchOpencodeUsage: opts.opencode ?? (async () => null),
- };
- return createKeyUsageTool(callbacks);
-}
-
-const HOUR = 3_600_000;
-
-describe("key_usage tool", () => {
- it("reports all keys when no key_id is given", async () => {
- const reset5h = Date.now() + 2 * HOUR;
- const tool = buildTool({
- keys: [
- keyState({ id: "claude-max", provider: "anthropic", credentials_file: "/creds/max.json" }),
- keyState({ id: "opencode-1", provider: "opencode-go" }),
- ],
- accounts: [account("claude-max", "/creds/max.json")],
- anthropic: async () => ({
- source: "live",
- report: {
- fiveHour: { utilization: 0.25, resetsAt: reset5h },
- sevenDay: { utilization: 0.6 },
- },
- }),
- opencode: async () => ({
- fiveHour: { utilization: 0.1 },
- weekly: { utilization: 0.4 },
- monthly: { utilization: 0.7 },
- }),
- });
-
- const out = await tool.execute({});
-
- // Both keys present with providers.
- expect(out).toContain("[claude-max] provider: anthropic");
- expect(out).toContain("[opencode-1] provider: opencode-go");
- // Remaining = (1 - utilization) * 100.
- expect(out).toContain("5-hour: 75% remaining");
- expect(out).toContain("week: 40% remaining");
- expect(out).toContain("5-hour: 90% remaining");
- expect(out).toContain("week: 60% remaining");
- expect(out).toContain("month: 30% remaining");
- expect(out).toContain("data: live (fetched just now)");
- });
-
- it("filters to a single key when key_id is given and does not fetch others", async () => {
- const opencodeFetch = vi.fn(async () => ({ fiveHour: { utilization: 0.5 } }));
- const tool = buildTool({
- keys: [
- keyState({ id: "claude-max", provider: "anthropic" }),
- keyState({ id: "opencode-1", provider: "opencode-go" }),
- ],
- accounts: [account("claude-max")],
- anthropic: async () => ({
- source: "live",
- report: { fiveHour: { utilization: 0.2 } },
- }),
- opencode: opencodeFetch,
- });
-
- const out = await tool.execute({ key_id: "claude-max" });
-
- expect(out).toContain("[claude-max] provider: anthropic");
- expect(out).not.toContain("opencode-1");
- expect(opencodeFetch).not.toHaveBeenCalled();
- });
-
- it("returns a helpful error for an unknown key_id", async () => {
- const tool = buildTool({
- keys: [
- keyState({ id: "claude-max", provider: "anthropic" }),
- keyState({ id: "opencode-1", provider: "opencode-go" }),
- ],
- });
-
- const out = await tool.execute({ key_id: "nope" });
-
- expect(out).toContain('no key found with id "nope"');
- expect(out).toContain("claude-max");
- expect(out).toContain("opencode-1");
- });
-
- it("reports cached data with the source's last-fetched timestamp", async () => {
- const cachedAt = Date.UTC(2025, 0, 2, 3, 4, 5);
- const tool = buildTool({
- keys: [keyState({ id: "claude-max", provider: "anthropic" })],
- accounts: [account("claude-max")],
- anthropic: async () => ({
- source: "cache",
- cachedAt,
- report: { fiveHour: { utilization: 0.5 } },
- }),
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("data: cached — last fetched from source 2025-01-02T03:04:05.000Z");
- expect(out).toContain("5-hour: 50% remaining");
- });
-
- it("omits the month window for anthropic (no monthly bucket)", async () => {
- const tool = buildTool({
- keys: [keyState({ id: "claude-max", provider: "anthropic" })],
- accounts: [account("claude-max")],
- anthropic: async () => ({
- source: "live",
- report: { fiveHour: { utilization: 0.1 }, sevenDay: { utilization: 0.2 } },
- }),
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("5-hour:");
- expect(out).toContain("week:");
- expect(out).not.toContain("month:");
- });
-
- it("includes the month window for opencode-go", async () => {
- const tool = buildTool({
- keys: [keyState({ id: "opencode-1", provider: "opencode-go" })],
- opencode: async () => ({
- fiveHour: { utilization: 0.1 },
- weekly: { utilization: 0.2 },
- monthly: { utilization: 0.3 },
- }),
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("month: 70% remaining");
- });
-
- it("surfaces exhausted status with the last error", async () => {
- const exhaustedAt = Date.now() - HOUR;
- const tool = buildTool({
- keys: [
- keyState(
- { id: "opencode-1", provider: "opencode-go" },
- { status: "exhausted", lastError: "429 rate limit exceeded", exhaustedAt },
- ),
- ],
- opencode: async () => null,
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("status: EXHAUSTED");
- expect(out).toContain("last error: 429 rate limit exceeded");
- });
-
- it("flags providers without usage support", async () => {
- const tool = buildTool({
- keys: [keyState({ id: "gem", provider: "google" })],
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("[gem] provider: google");
- expect(out).toContain("not supported");
- });
-
- it("reports unavailable when a supported provider returns no usage", async () => {
- const tool = buildTool({
- keys: [keyState({ id: "claude-max", provider: "anthropic" })],
- accounts: [account("claude-max")],
- anthropic: async () => null,
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("usage: unavailable");
- expect(out).toContain("no cached usage");
- });
-
- it("reports unavailable for anthropic keys with no account credentials", async () => {
- const tool = buildTool({
- keys: [keyState({ id: "claude-max", provider: "anthropic" })],
- accounts: [],
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("no Claude account credentials available");
- });
-
- it("treats a fetcher that throws as unavailable (does not crash)", async () => {
- const tool = buildTool({
- keys: [keyState({ id: "opencode-1", provider: "opencode-go" })],
- opencode: async () => {
- throw new Error("network down");
- },
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("usage: unavailable");
- });
-
- it("reports when no keys are configured at all", async () => {
- const tool = buildTool({ keys: [] });
- const out = await tool.execute({});
- expect(out).toBe("No API keys are configured.");
- });
-
- it("clamps out-of-range utilization to 0–100%", async () => {
- const tool = buildTool({
- keys: [keyState({ id: "opencode-1", provider: "opencode-go" })],
- opencode: async () => ({
- fiveHour: { utilization: 1.2 }, // over 100% used → 0% remaining
- weekly: { utilization: -0.5 }, // negative → 100% remaining
- }),
- });
-
- const out = await tool.execute({});
-
- expect(out).toContain("5-hour: 0% remaining");
- expect(out).toContain("week: 100% remaining");
- });
-});
-
-describe("formatKeyUsage (pure)", () => {
- const now = Date.UTC(2025, 5, 1, 12, 0, 0);
-
- it("formats reset timestamps with ISO + relative time", () => {
- const out = formatKeyUsage(
- [
- {
- keyId: "claude-max",
- provider: "anthropic",
- status: "active",
- dataSource: "live",
- windows: [{ label: "5-hour", remainingPercent: 80, resetsAt: now + 90 * 60_000 }],
- },
- ],
- now,
- );
-
- expect(out).toContain("5-hour: 80% remaining, resets 2025-06-01T13:30:00.000Z (in 1h 30m)");
- });
-
- it("renders a past reset/exhaustion time as 'ago'", () => {
- const out = formatKeyUsage(
- [
- {
- keyId: "opencode-1",
- provider: "opencode-go",
- status: "exhausted",
- exhaustedAt: now - 2 * HOUR,
- lastError: "boom",
- windows: [],
- },
- ],
- now,
- );
-
- expect(out).toContain("status: EXHAUSTED (since 2025-06-01T10:00:00.000Z, 2h ago)");
- expect(out).toContain("last error: boom");
- });
-
- it("returns a friendly message when no entries match", () => {
- expect(formatKeyUsage([], now)).toBe("No API keys matched.");
- });
-});
diff --git a/packages/core/tests/tools/list-files.test.ts b/packages/core/tests/tools/list-files.test.ts
deleted file mode 100644
index f371717..0000000
--- a/packages/core/tests/tools/list-files.test.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import { createListFilesTool } from "../../src/tools/list-files.js";
-
-describe("list_files tool", () => {
- let workDir: string;
-
- beforeEach(async () => {
- workDir = await mkdtemp(join(tmpdir(), "dispatch-test-"));
- });
-
- afterEach(async () => {
- await rm(workDir, { recursive: true, force: true });
- });
-
- it("lists directory contents", async () => {
- const tool = createListFilesTool(workDir);
- await writeFile(join(workDir, "file1.txt"), "a");
- await writeFile(join(workDir, "file2.txt"), "b");
- await mkdir(join(workDir, "subdir"));
- const result = await tool.execute({ path: "." });
- expect(result).toContain("file1.txt");
- expect(result).toContain("file2.txt");
- expect(result).toContain("subdir/");
- });
-
- it("defaults to current directory when path is undefined", async () => {
- const tool = createListFilesTool(workDir);
- await writeFile(join(workDir, "hello.txt"), "hi");
- const result = await tool.execute({});
- expect(result).toContain("hello.txt");
- });
-
- it("blocks path traversal", async () => {
- const tool = createListFilesTool(workDir);
- const result = await tool.execute({ path: "../" });
- expect(result).toMatch(/outside the working directory/i);
- });
-
- // Regression for `resolve(join(workingDirectory, relPath))` — when relPath
- // is absolute, `join` does NOT short-circuit. The old code silently
- // rewrote `/some/path` to `<workdir>/some/path` and either returned an
- // ENOENT-style error or, worse, listed an unrelated path. After the fix,
- // absolute paths resolve to themselves and the workdir gate behaves correctly.
- describe("absolute path handling", () => {
- it("lists an absolute path that lives under the workdir", async () => {
- const tool = createListFilesTool(workDir);
- await writeFile(join(workDir, "alpha.txt"), "a");
- await writeFile(join(workDir, "beta.txt"), "b");
- const result = await tool.execute({ path: workDir });
- expect(result).toContain("alpha.txt");
- expect(result).toContain("beta.txt");
- // "Error listing files" would indicate the path was mangled into a
- // non-existent location.
- expect(result).not.toMatch(/error listing/i);
- });
-
- it("rejects absolute paths outside the workdir with the workdir error (not a generic ENOENT)", async () => {
- const tool = createListFilesTool(workDir);
- // Use a tmpdir path that's definitely not under workDir. Under the
- // bug, this got rewritten to `<workdir>/tmp/...` and produced an
- // `Error listing files` ENOENT message instead of the workdir error.
- const evilPath = join(tmpdir(), `dispatch-evil-${Date.now()}`);
- const result = await tool.execute({ path: evilPath });
- expect(result).toMatch(/outside the working directory/i);
- });
- });
-
- // A directory symlink inside the workdir pointing to an external
- // directory is the classic escape vector for a `ls` style tool.
- // `canonicalize` must resolve the symlink so the listing is denied.
- describe("symlink handling", () => {
- let externalDir: string;
-
- beforeEach(async () => {
- externalDir = await mkdtemp(join(tmpdir(), "dispatch-external-"));
- await writeFile(join(externalDir, "secret.txt"), "secret");
- });
-
- afterEach(async () => {
- await rm(externalDir, { recursive: true, force: true });
- });
-
- it("blocks listing through a symlinked directory that escapes the workdir", async () => {
- const tool = createListFilesTool(workDir);
- await symlink(externalDir, join(workDir, "peek"));
- const result = await tool.execute({ path: "peek" });
- expect(result).toMatch(/outside the working directory/i);
- expect(result).not.toContain("secret.txt");
- });
- });
-});
diff --git a/packages/core/tests/tools/lsp-tool.test.ts b/packages/core/tests/tools/lsp-tool.test.ts
deleted file mode 100644
index 7f26522..0000000
--- a/packages/core/tests/tools/lsp-tool.test.ts
+++ /dev/null
@@ -1,110 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import type { LspManager } from "../../src/lsp/manager.js";
-import type { ResolvedLspServer } from "../../src/lsp/server.js";
-import { createLspTool, type LspToolContext } from "../../src/tools/lsp.js";
-
-const SERVER: ResolvedLspServer = {
- id: "luau-lsp",
- extensions: [".luau"],
- spawn: () => ({ process: {} as never }),
-};
-
-function makeManager(overrides: Partial<LspManager> = {}): LspManager {
- return {
- hasServerForFile: vi.fn(() => true),
- touchFile: vi.fn(async () => {}),
- getDiagnostics: vi.fn(() => ({})),
- request: vi.fn(async () => []),
- getClients: vi.fn(async () => []),
- shutdownAll: vi.fn(async () => {}),
- ...overrides,
- } as unknown as LspManager;
-}
-
-function ctx(manager: LspManager, servers = [SERVER]): () => LspToolContext {
- return () => ({ manager, workingDirectory: "/work", servers });
-}
-
-describe("createLspTool", () => {
- it("exposes the expected schema/name", () => {
- const tool = createLspTool(ctx(makeManager()));
- expect(tool.name).toBe("lsp");
- expect(tool.description).toMatch(/luau-lsp/i);
- });
-
- it("errors when no servers are configured", async () => {
- const tool = createLspTool(ctx(makeManager(), []));
- const out = await tool.execute({ operation: "diagnostics", path: "a.luau" });
- expect(out).toMatch(/no LSP servers are configured/i);
- });
-
- it("errors when no server matches the file", async () => {
- const manager = makeManager({ hasServerForFile: vi.fn(() => false) as never });
- const tool = createLspTool(ctx(manager));
- const out = await tool.execute({ operation: "diagnostics", path: "a.ts" });
- expect(out).toMatch(/no configured LSP server matches/i);
- });
-
- it("diagnostics: touches the file then reports errors", async () => {
- const touchFile = vi.fn(async () => {});
- const getDiagnostics = vi.fn(() => ({
- "/work/a.luau": [
- {
- range: { start: { line: 2, character: 1 }, end: { line: 2, character: 9 } },
- severity: 1,
- message: "bad type",
- },
- ],
- }));
- const manager = makeManager({
- touchFile: touchFile as never,
- getDiagnostics: getDiagnostics as never,
- });
- const tool = createLspTool(ctx(manager));
- const out = await tool.execute({ operation: "diagnostics", path: "a.luau" });
- expect(touchFile).toHaveBeenCalledOnce();
- expect(out).toContain("ERROR [3:2] bad type");
- });
-
- it("diagnostics: reports clean when no errors", async () => {
- const tool = createLspTool(ctx(makeManager()));
- const out = await tool.execute({ operation: "diagnostics", path: "a.luau" });
- expect(out).toMatch(/No errors reported/i);
- });
-
- it("hover: requires line and character", async () => {
- const tool = createLspTool(ctx(makeManager()));
- const out = await tool.execute({ operation: "hover", path: "a.luau" });
- expect(out).toMatch(/requires both 'line' and 'character'/i);
- });
-
- it("hover: converts 1-based coords to 0-based on the wire", async () => {
- const request = vi.fn(async () => [{ contents: "hi" }]);
- const manager = makeManager({ request: request as never });
- const tool = createLspTool(ctx(manager));
- await tool.execute({ operation: "hover", path: "a.luau", line: 5, character: 3 });
- expect(request).toHaveBeenCalledOnce();
- const arg = request.mock.calls[0]?.[0] as { method: string; params: { position: unknown } };
- expect(arg.method).toBe("textDocument/hover");
- expect(arg.params.position).toEqual({ line: 4, character: 2 });
- });
-
- it("references: includes declaration context", async () => {
- const request = vi.fn(async () => []);
- const manager = makeManager({ request: request as never });
- const tool = createLspTool(ctx(manager));
- await tool.execute({ operation: "references", path: "a.luau", line: 1, character: 1 });
- const arg = request.mock.calls[0]?.[0] as { params: { context?: unknown } };
- expect(arg.params.context).toEqual({ includeDeclaration: true });
- });
-
- it("documentSymbol: does not require a position", async () => {
- const request = vi.fn(async () => [{ name: "foo" }]);
- const manager = makeManager({ request: request as never });
- const tool = createLspTool(ctx(manager));
- const out = await tool.execute({ operation: "documentSymbol", path: "a.luau" });
- const arg = request.mock.calls[0]?.[0] as { method: string };
- expect(arg.method).toBe("textDocument/documentSymbol");
- expect(out).toContain("foo");
- });
-});
diff --git a/packages/core/tests/tools/read-file.test.ts b/packages/core/tests/tools/read-file.test.ts
deleted file mode 100644
index 90165d8..0000000
--- a/packages/core/tests/tools/read-file.test.ts
+++ /dev/null
@@ -1,142 +0,0 @@
-import { randomBytes } from "node:crypto";
-import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import { createReadFileTool } from "../../src/tools/read-file.js";
-import { SPILL_ROOT } from "../../src/tools/truncate.js";
-
-describe("read_file tool", () => {
- let workDir: string;
-
- beforeEach(async () => {
- workDir = await mkdtemp(join(tmpdir(), "dispatch-test-"));
- });
-
- afterEach(async () => {
- await rm(workDir, { recursive: true, force: true });
- });
-
- it("reads an existing file", async () => {
- const tool = createReadFileTool(workDir);
- await writeFile(join(workDir, "hello.txt"), "Hello, world!");
- const result = await tool.execute({ path: "hello.txt" });
- expect(result).toContain("Hello, world!");
- expect(result).toContain("[file: hello.txt — lines 1-1 of 1]");
- });
-
- it("returns error for non-existent file", async () => {
- const tool = createReadFileTool(workDir);
- const result = await tool.execute({ path: "missing.txt" });
- expect(result).toMatch(/not found/i);
- });
-
- it("blocks path traversal", async () => {
- const tool = createReadFileTool(workDir);
- const result = await tool.execute({ path: "../etc/passwd" });
- expect(result).toMatch(/outside the working directory/i);
- });
-
- it("respects offset and limit", async () => {
- const tool = createReadFileTool(workDir);
- await writeFile(join(workDir, "multi.txt"), "line1\nline2\nline3\nline4\nline5");
- const result = await tool.execute({ path: "multi.txt", offset: 2, limit: 2 });
- expect(result).toContain("line2");
- expect(result).toContain("line3");
- expect(result).not.toContain("line1");
- expect(result).not.toContain("line4");
- expect(result).toContain("[file: multi.txt — lines 2-3 of 5]");
- });
-
- it("truncates long lines and points to read_file_slice", async () => {
- const tool = createReadFileTool(workDir);
- const longLine = "x".repeat(3000);
- await writeFile(join(workDir, "wide.txt"), longLine);
- const result = await tool.execute({ path: "wide.txt" });
- expect(result).toContain("[line 1 truncated, total 3,000 chars");
- expect(result).toContain("use read_file_slice");
- });
-
- // The universal truncator writes oversized tool output to
- // `${SPILL_ROOT}/<tabId>/<callId>.txt` and the truncation notice tells
- // the AI to read that absolute path back. A previous implementation
- // used `resolve(join(workingDirectory, filePath))` which silently
- // concatenated the absolute spill path *under* the workdir, producing
- // a non-existent path and ENOENT — breaking the entire spill-and-resume
- // flow. These tests guard that contract.
- describe("absolute path handling (spill-file regression)", () => {
- let spillSubdir: string;
-
- beforeEach(async () => {
- spillSubdir = join(SPILL_ROOT, `test-${Date.now()}-${randomBytes(4).toString("hex")}`);
- await mkdir(spillSubdir, { recursive: true });
- });
-
- afterEach(async () => {
- await rm(spillSubdir, { recursive: true, force: true });
- });
-
- it("reads a spill file via its absolute path", async () => {
- const tool = createReadFileTool(workDir);
- const spillFile = join(spillSubdir, "call-abc.txt");
- const payload = "spilled output line 1\nspilled output line 2";
- await writeFile(spillFile, payload);
-
- const result = await tool.execute({ path: spillFile });
-
- expect(result).toContain("spilled output line 1");
- expect(result).toContain("spilled output line 2");
- expect(result).not.toMatch(/not found/i);
- expect(result).not.toMatch(/outside the working directory/i);
- });
-
- it("still rejects absolute paths that are neither in the workdir nor the spill root", async () => {
- const tool = createReadFileTool(workDir);
- // Path check happens before file read, so /etc/hostname existing
- // (or not) is irrelevant — we just need an absolute path outside
- // both the workdir and SPILL_ROOT.
- const result = await tool.execute({ path: "/etc/hostname" });
- expect(result).toMatch(/outside the working directory/i);
- });
- });
-
- // Symlinks must resolve consistently across the agent permission gate
- // and the tool itself. The containment check operates on the canonical
- // path — so a symlink-in-workdir that points outside is treated as
- // "outside" and gated like any other external path. Lexical-only
- // checks would let these slip through silently.
- describe("symlink handling", () => {
- let externalDir: string;
-
- beforeEach(async () => {
- externalDir = await mkdtemp(join(tmpdir(), "dispatch-external-"));
- });
-
- afterEach(async () => {
- await rm(externalDir, { recursive: true, force: true });
- });
-
- it("follows symlinks that stay inside the workdir", async () => {
- const tool = createReadFileTool(workDir);
- await writeFile(join(workDir, "real.txt"), "real content");
- await symlink(join(workDir, "real.txt"), join(workDir, "link.txt"));
- const result = await tool.execute({ path: "link.txt" });
- expect(result).toContain("real content");
- expect(result).not.toMatch(/outside the working directory/i);
- });
-
- it("blocks symlinks that escape the workdir", async () => {
- const tool = createReadFileTool(workDir);
- const secret = join(externalDir, "secret.txt");
- await writeFile(secret, "leaked secret");
- // Create a symlink *inside* workDir pointing to a file *outside*
- // workDir. Lexical-only path validation would see "workdir/trap.txt"
- // (under workdir) and allow it. Canonical resolution sees the
- // symlink's target and correctly rejects.
- await symlink(secret, join(workDir, "trap.txt"));
- const result = await tool.execute({ path: "trap.txt" });
- expect(result).toMatch(/outside the working directory/i);
- expect(result).not.toContain("leaked secret");
- });
- });
-});
diff --git a/packages/core/tests/tools/read-tab.test.ts b/packages/core/tests/tools/read-tab.test.ts
deleted file mode 100644
index 71e419c..0000000
--- a/packages/core/tests/tools/read-tab.test.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { createReadTabTool, type ReadTabCallbacks } from "../../src/tools/read-tab.js";
-import type { TabResolution } from "../../src/tools/send-to-tab.js";
-
-function makeCallbacks(overrides: Partial<ReadTabCallbacks> = {}): ReadTabCallbacks {
- return {
- resolveShortId: (): TabResolution => ({
- status: "ok",
- tab: { id: "target-id", title: "Target", handle: "targ" },
- }),
- getLastResponse: () => ({ text: "the answer is 42", status: "idle" }),
- listOpenHandles: () => [{ handle: "targ", title: "Target" }],
- ...overrides,
- };
-}
-
-describe("createReadTabTool — schema & description", () => {
- it("is a non-blocking snapshot read", () => {
- const tool = createReadTabTool(makeCallbacks());
- expect(tool.name).toBe("read_tab");
- expect(tool.description).toContain("SNAPSHOT");
- expect(tool.description.toLowerCase()).toContain("does not block");
- });
-});
-
-describe("createReadTabTool — execute()", () => {
- it("returns the last assistant response wrapped in a tab_response tag", async () => {
- const tool = createReadTabTool(makeCallbacks());
- const out = await tool.execute({ tab_id: "targ" });
- expect(out).toContain("<tab_response");
- expect(out).toContain('tab="targ"');
- expect(out).toContain('status="idle"');
- expect(out).toContain("the answer is 42");
- expect(out).toContain("</tab_response>");
- });
-
- it("notes that a running tab's response is its previous completed turn", async () => {
- const tool = createReadTabTool(
- makeCallbacks({
- getLastResponse: () => ({ text: "older turn", status: "running" }),
- }),
- );
- const out = await tool.execute({ tab_id: "targ" });
- expect(out).toContain("still running");
- expect(out).toContain("older turn");
- });
-
- it("explains when a tab has no completed response yet (idle)", async () => {
- const tool = createReadTabTool(
- makeCallbacks({
- getLastResponse: () => ({ text: null, status: "idle" }),
- }),
- );
- const out = await tool.execute({ tab_id: "targ" });
- expect(out).toContain("no completed response");
- expect(out).toContain("no assistant responses yet");
- });
-
- it("explains when a tab is still on its first turn (running, no prior text)", async () => {
- const tool = createReadTabTool(
- makeCallbacks({
- getLastResponse: () => ({ text: null, status: "running" }),
- }),
- );
- const out = await tool.execute({ tab_id: "targ" });
- expect(out).toContain("no completed response");
- expect(out).toContain("still working on its first turn");
- });
-
- it("rejects an empty tab_id and lists open handles", async () => {
- const tool = createReadTabTool(makeCallbacks());
- const out = await tool.execute({ tab_id: "" });
- expect(out).toContain("Error");
- expect(out).toContain("targ");
- });
-
- it("returns a helpful error when the id is unknown", async () => {
- const tool = createReadTabTool(makeCallbacks({ resolveShortId: () => ({ status: "none" }) }));
- const out = await tool.execute({ tab_id: "zzzz" });
- expect(out).toContain("no open tab matches");
- expect(out).toContain("Currently open tabs:");
- });
-
- it("asks for more characters when the id is ambiguous", async () => {
- const tool = createReadTabTool(
- makeCallbacks({
- resolveShortId: () => ({
- status: "ambiguous",
- matches: [
- { id: "a1", title: "One", handle: "abcd1" },
- { id: "a2", title: "Two", handle: "abcd2" },
- ],
- }),
- }),
- );
- const out = await tool.execute({ tab_id: "abcd" });
- expect(out).toContain("ambiguous");
- expect(out).toContain("abcd1");
- expect(out).toContain("abcd2");
- });
-});
diff --git a/packages/core/tests/tools/registry.test.ts b/packages/core/tests/tools/registry.test.ts
deleted file mode 100644
index cad75d2..0000000
--- a/packages/core/tests/tools/registry.test.ts
+++ /dev/null
@@ -1,143 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { z } from "zod";
-import { createToolRegistry } from "../../src/tools/registry.js";
-import type { ToolDefinition } from "../../src/types/index.js";
-
-const mockTool: ToolDefinition = {
- name: "mock_tool",
- description: "A mock tool for testing",
- parameters: z.object({ input: z.string() }),
- execute: async (_args) => "mock result",
-};
-
-const anotherTool: ToolDefinition = {
- name: "another_tool",
- description: "Another mock tool",
- parameters: z.object({ value: z.number() }),
- execute: async (_args) => "another result",
-};
-
-/** A non-trivial tool that exercises nested objects, required fields, and enums. */
-const complexTool: ToolDefinition = {
- name: "complex_tool",
- description: "A tool with nested parameters",
- parameters: z.object({
- command: z.string().describe("Shell command to run"),
- options: z.object({
- timeout: z.number().optional().describe("Timeout in milliseconds"),
- shell: z.enum(["bash", "sh", "zsh"]).describe("Shell to use"),
- }),
- flags: z.array(z.string()).optional().describe("Additional flags"),
- }),
- execute: async (_args) => "complex result",
-};
-
-describe("createToolRegistry", () => {
- it("returns all tools via getTools()", () => {
- const registry = createToolRegistry([mockTool, anotherTool]);
- const tools = registry.getTools();
- expect(tools).toHaveLength(2);
- expect(tools.map((t) => t.name)).toContain("mock_tool");
- expect(tools.map((t) => t.name)).toContain("another_tool");
- });
-
- it("retrieves specific tool by name", () => {
- const registry = createToolRegistry([mockTool, anotherTool]);
- const tool = registry.getTool("mock_tool");
- expect(tool).toBeDefined();
- expect(tool?.name).toBe("mock_tool");
- });
-
- it("returns undefined for unknown tool", () => {
- const registry = createToolRegistry([mockTool]);
- expect(registry.getTool("nonexistent")).toBeUndefined();
- });
-
- describe("getAISDKTools", () => {
- it("returns correct keys for all tools", () => {
- const registry = createToolRegistry([mockTool, anotherTool]);
- const aiTools = registry.getAISDKTools();
- expect(aiTools).toHaveProperty("mock_tool");
- expect(aiTools).toHaveProperty("another_tool");
- });
-
- it("AI SDK tools have description from ToolDefinition", () => {
- const registry = createToolRegistry([mockTool]);
- const aiTools = registry.getAISDKTools();
- expect(aiTools.mock_tool.description).toBe("A mock tool for testing");
- });
-
- it("AI SDK tools surface schema via inputSchema, not parameters", () => {
- const registry = createToolRegistry([mockTool]);
- const aiTools = registry.getAISDKTools();
- // v6 uses inputSchema; v4 used parameters — this verifies the migration
- expect(aiTools.mock_tool).toHaveProperty("inputSchema");
- expect(aiTools.mock_tool).not.toHaveProperty("parameters");
- });
-
- it("AI SDK tools have no execute callback so the SDK does not auto-run", () => {
- const registry = createToolRegistry([mockTool, anotherTool, complexTool]);
- const aiTools = registry.getAISDKTools();
- for (const [name, sdkTool] of Object.entries(aiTools)) {
- expect(
- (sdkTool as Record<string, unknown>).execute,
- `Tool "${name}" should not have an execute callback`,
- ).toBeUndefined();
- }
- });
-
- it("inputSchema produces valid JSONSchema7 for a simple tool", () => {
- const registry = createToolRegistry([mockTool]);
- const aiTools = registry.getAISDKTools();
- const schema = aiTools.mock_tool.inputSchema;
- // jsonSchema() wraps the raw JSONSchema7; it should expose the schema
- // as a `jsonSchema` property on the Schema object
- expect(schema).toBeDefined();
- // The wrapped schema object should carry the JSON Schema definition
- const schemaObj = schema as { jsonSchema: Record<string, unknown> };
- expect(schemaObj.jsonSchema).toBeDefined();
- expect(schemaObj.jsonSchema.type).toBe("object");
- const props = schemaObj.jsonSchema.properties as Record<string, unknown>;
- expect(props).toHaveProperty("input");
- });
-
- it("inputSchema produces correct JSONSchema7 for a non-trivial nested tool", () => {
- const registry = createToolRegistry([complexTool]);
- const aiTools = registry.getAISDKTools();
- const schema = aiTools.complex_tool.inputSchema;
- expect(schema).toBeDefined();
- const schemaObj = schema as { jsonSchema: Record<string, unknown> };
- expect(schemaObj.jsonSchema.type).toBe("object");
-
- const props = schemaObj.jsonSchema.properties as Record<string, Record<string, unknown>>;
-
- // Top-level required field "command"
- expect(props).toHaveProperty("command");
- expect(props.command.type).toBe("string");
-
- // Nested object "options"
- expect(props).toHaveProperty("options");
- expect(props.options.type).toBe("object");
- const optProps = props.options.properties as Record<string, Record<string, unknown>>;
- expect(optProps).toHaveProperty("shell");
- expect(optProps.shell.enum).toEqual(["bash", "sh", "zsh"]);
-
- // Optional array "flags" present as a property
- expect(props).toHaveProperty("flags");
- expect(props.flags.type).toBe("array");
-
- // Required fields should include "command" and "options"
- const required = schemaObj.jsonSchema.required as string[];
- expect(required).toContain("command");
- expect(required).toContain("options");
- });
-
- it("getTool still returns the original ToolDefinition with execute", () => {
- const registry = createToolRegistry([mockTool]);
- const def = registry.getTool("mock_tool");
- expect(def).toBeDefined();
- expect(typeof def?.execute).toBe("function");
- expect(def?.name).toBe("mock_tool");
- });
- });
-});
diff --git a/packages/core/tests/tools/run-shell.test.ts b/packages/core/tests/tools/run-shell.test.ts
deleted file mode 100644
index cb66d1c..0000000
--- a/packages/core/tests/tools/run-shell.test.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-import { mkdtemp, rm } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { createRunShellTool } from "../../src/tools/run-shell.js";
-
-describe("run_shell tool", () => {
- let workDir: string;
-
- beforeEach(async () => {
- workDir = await mkdtemp(join(tmpdir(), "dispatch-test-"));
- });
-
- afterEach(async () => {
- await rm(workDir, { recursive: true, force: true });
- });
-
- it("executes a simple echo command", async () => {
- const tool = createRunShellTool(workDir);
- const raw = await tool.execute({ command: "echo hello" });
- const result = JSON.parse(raw);
- expect(result.stdout.trim()).toBe("hello");
- expect(result.exitCode).toBe(0);
- });
-
- it("returns non-zero exit code on failure", async () => {
- const tool = createRunShellTool(workDir);
- const raw = await tool.execute({ command: "exit 42" });
- const result = JSON.parse(raw);
- expect(result.exitCode).toBe(42);
- });
-
- it("captures stderr", async () => {
- const tool = createRunShellTool(workDir);
- const raw = await tool.execute({ command: "echo errormsg >&2" });
- const result = JSON.parse(raw);
- expect(result.stderr.trim()).toBe("errormsg");
- });
-
- it("handles timeout", async () => {
- const tool = createRunShellTool(workDir);
- const raw = await tool.execute({ command: "sleep 10", timeout: 100 });
- const result = JSON.parse(raw);
- // Either times out (non-zero exit) or returns an error
- expect(result.exitCode !== 0 || result.error !== undefined).toBe(true);
- }, 5000);
-
- it("executes in the working directory", async () => {
- const tool = createRunShellTool(workDir);
- const raw = await tool.execute({ command: "pwd" });
- const result = JSON.parse(raw);
- // On macOS /tmp is symlinked; use includes check
- expect(result.stdout.trim()).toContain(workDir.replace(/^\/private/, ""));
- });
-
- it("calls onOutput callback with stdout chunks", async () => {
- const tool = createRunShellTool(workDir);
- const onOutput = vi.fn();
- const raw = await tool.execute({ command: "echo streaming" }, { onOutput });
- const result = JSON.parse(raw);
- expect(result.stdout.trim()).toBe("streaming");
- expect(onOutput).toHaveBeenCalledWith(expect.stringContaining("streaming"), "stdout");
- });
-
- it("calls onOutput callback with stderr chunks", async () => {
- const tool = createRunShellTool(workDir);
- const onOutput = vi.fn();
- await tool.execute({ command: "echo errdata >&2" }, { onOutput });
- expect(onOutput).toHaveBeenCalledWith(expect.stringContaining("errdata"), "stderr");
- });
-
- it("works without context (backward compatible)", async () => {
- const tool = createRunShellTool(workDir);
- const raw = await tool.execute({ command: "echo nocontext" });
- const result = JSON.parse(raw);
- expect(result.stdout.trim()).toBe("nocontext");
- });
-});
diff --git a/packages/core/tests/tools/search-code.test.ts b/packages/core/tests/tools/search-code.test.ts
deleted file mode 100644
index c4e933c..0000000
--- a/packages/core/tests/tools/search-code.test.ts
+++ /dev/null
@@ -1,511 +0,0 @@
-import { spawnSync } from "node:child_process";
-import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
-import { mkdtemp as mkdtempP, rm as rmP, writeFile as writeFileP } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import { createSearchCodeTool } from "../../src/tools/search-code.js";
-
-// A tiny stub that impersonates `cs`: it ignores its args and prints whatever
-// JSON we put in the CS_STUB_OUTPUT env var. This makes JSON→text formatting
-// tests fully deterministic without needing a real cs binary in CI.
-function writeStub(dir: string, body: string): string {
- const stubPath = join(dir, "cs-stub.sh");
- writeFileSync(stubPath, body, { mode: 0o755 });
- chmodSync(stubPath, 0o755);
- return stubPath;
-}
-
-const ECHO_ENV_STUB = `#!/usr/bin/env bash
-printf '%s' "$CS_STUB_OUTPUT"
-`;
-
-// A stub that writes to stderr and exits non-zero, impersonating a cs failure
-// (bad flag, invalid regex, etc.).
-const FAIL_STUB = `#!/usr/bin/env bash
-echo "cs: simulated failure on stderr" >&2
-exit 3
-`;
-
-describe("search_code tool", () => {
- let workDir: string;
- const savedBin = process.env.DISPATCH_CS_BIN;
- const savedStubOut = process.env.CS_STUB_OUTPUT;
-
- beforeEach(async () => {
- workDir = await mkdtempP(join(tmpdir(), "dispatch-cs-test-"));
- });
-
- afterEach(async () => {
- await rmP(workDir, { recursive: true, force: true });
- if (savedBin === undefined) delete process.env.DISPATCH_CS_BIN;
- else process.env.DISPATCH_CS_BIN = savedBin;
- if (savedStubOut === undefined) delete process.env.CS_STUB_OUTPUT;
- else process.env.CS_STUB_OUTPUT = savedStubOut;
- });
-
- it("exposes the expected name and schema", () => {
- const tool = createSearchCodeTool(workDir);
- expect(tool.name).toBe("search_code");
- expect(tool.description).toContain("cs");
- // query is required; a representative set of optional knobs exist.
- const shape = (tool.parameters as unknown as { shape: Record<string, unknown> }).shape;
- expect(shape.query).toBeDefined();
- expect(shape.path).toBeDefined();
- expect(shape.only).toBeDefined();
- expect(shape.result_limit).toBeDefined();
- });
-
- it("requires a non-empty query", async () => {
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: " " });
- expect(out).toMatch(/^Error:/);
- expect(out).toContain("query is required");
- });
-
- it("does not crash when params are the wrong type (model hallucination)", async () => {
- const tool = createSearchCodeTool(workDir);
- // A non-string query must be rejected gracefully, not throw.
- const q = await tool.execute({ query: ["a", "b"] as unknown as string });
- expect(q).toMatch(/^Error:/);
- expect(q).toContain("query is required");
- // A non-string include_ext (array) must not throw "x.trim is not a function".
- const stubDir = await mkdtempP(join(tmpdir(), "dispatch-cs-stub-"));
- try {
- process.env.DISPATCH_CS_BIN = writeStub(stubDir, ECHO_ENV_STUB);
- process.env.CS_STUB_OUTPUT = "null";
- const out = await tool.execute({
- query: "x",
- include_ext: ["ts", "go"] as unknown as string,
- exclude_pattern: { a: 1 } as unknown as string,
- });
- expect(out).toBe("No matches found.");
- } finally {
- await rmP(stubDir, { recursive: true, force: true });
- }
- });
-
- it("rejects a path outside the working directory", async () => {
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "anything", path: "../../etc" });
- expect(out).toMatch(/^Error:/);
- expect(out).toContain("outside the working directory");
- });
-
- it("rejects a path that points at a file, not a directory", async () => {
- await writeFileP(join(workDir, "a-file.ts"), "const x = 1;\n");
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "x", path: "a-file.ts" });
- expect(out).toMatch(/^Error:/);
- expect(out).toContain("is a file, not a directory");
- });
-
- it("rejects a path that does not exist", async () => {
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "x", path: "no/such/dir" });
- expect(out).toMatch(/^Error:/);
- expect(out).toContain("does not exist");
- });
-
- it("returns an actionable error when the cs binary is missing", async () => {
- process.env.DISPATCH_CS_BIN = "/nonexistent/path/to/cs-binary-xyz";
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "anything" });
- expect(out).toMatch(/^Error:/);
- expect(out).toContain("requires the 'cs'");
- expect(out).toContain("DISPATCH_CS_BIN");
- });
-
- it("reports no matches when cs outputs null", async () => {
- const stubDir = await mkdtempP(join(tmpdir(), "dispatch-cs-stub-"));
- try {
- process.env.DISPATCH_CS_BIN = writeStub(stubDir, ECHO_ENV_STUB);
- process.env.CS_STUB_OUTPUT = "null";
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "nothinghere" });
- expect(out).toBe("No matches found.");
- } finally {
- await rmP(stubDir, { recursive: true, force: true });
- }
- });
-
- it("formats cs JSON results into readable per-file blocks", async () => {
- const stubDir = await mkdtempP(join(tmpdir(), "dispatch-cs-stub-"));
- try {
- const csJson = JSON.stringify([
- {
- filename: "web-search.ts",
- location: join(workDir, "packages/core/src/tools/web-search.ts"),
- score: 5.24,
- language: "TypeScript",
- total_lines: 106,
- lines: [
- { line_number: 7, content: "" },
- {
- line_number: 8,
- content: "export function createWebSearchTool(): ToolDefinition {",
- match_positions: [[16, 35]],
- },
- { line_number: 9, content: "\treturn {" },
- ],
- },
- {
- filename: "index.ts",
- location: join(workDir, "packages/core/src/index.ts"),
- score: 1.1,
- language: "TypeScript",
- lines: [{ line_number: 113, content: 'export { createWebSearchTool } from "./web.js";' }],
- },
- ]);
- process.env.DISPATCH_CS_BIN = writeStub(stubDir, ECHO_ENV_STUB);
- process.env.CS_STUB_OUTPUT = csJson;
-
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "createWebSearchTool" });
-
- expect(out).toContain("Found matches in 2 files");
- // Paths are rendered relative to the workdir.
- expect(out).toContain("packages/core/src/tools/web-search.ts [TypeScript] (score 5.24)");
- expect(out).not.toContain(workDir);
- // Matched line is marked with '>'; line numbers + content present.
- expect(out).toContain("> 8: export function createWebSearchTool(): ToolDefinition {");
- expect(out).toContain(" 7: ");
- expect(out).toContain("packages/core/src/index.ts [TypeScript] (score 1.10)");
- } finally {
- await rmP(stubDir, { recursive: true, force: true });
- }
- });
-
- it("renders cs 'content'-shape (prose) results instead of a bare header", async () => {
- const stubDir = await mkdtempP(join(tmpdir(), "dispatch-cs-stub-"));
- try {
- // cs's snippet mode emits `content` + `matchlocations` and no `lines`.
- const csJson = JSON.stringify([
- {
- filename: "notes.md",
- location: join(workDir, "docs/notes.md"),
- score: 0.42,
- language: "Markdown",
- content: "Some heading\nthe orchestration paragraph that matched",
- matchlocations: [[13, 26]],
- },
- ]);
- process.env.DISPATCH_CS_BIN = writeStub(stubDir, ECHO_ENV_STUB);
- process.env.CS_STUB_OUTPUT = csJson;
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "orchestration" });
- expect(out).toContain("docs/notes.md [Markdown] (score 0.42)");
- // The snippet text must be present, not a bare header.
- expect(out).toContain("the orchestration paragraph that matched");
- expect(out).not.toContain("no snippet available");
- } finally {
- await rmP(stubDir, { recursive: true, force: true });
- }
- });
-
- it("truncates an excessively long snippet line", async () => {
- const stubDir = await mkdtempP(join(tmpdir(), "dispatch-cs-stub-"));
- try {
- const longContent = `const x = "${"Z".repeat(5000)}";`;
- const csJson = JSON.stringify([
- {
- filename: "big.ts",
- location: join(workDir, "big.ts"),
- score: 1,
- language: "TypeScript",
- lines: [{ line_number: 1, content: longContent, match_positions: [[10, 14]] }],
- },
- ]);
- process.env.DISPATCH_CS_BIN = writeStub(stubDir, ECHO_ENV_STUB);
- process.env.CS_STUB_OUTPUT = csJson;
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "x" });
- expect(out).toContain("line truncated");
- // No single output line should approach the raw 5k length.
- const longest = Math.max(...out.split("\n").map((l) => l.length));
- expect(longest).toBeLessThan(700);
- } finally {
- await rmP(stubDir, { recursive: true, force: true });
- }
- });
-
- it("surfaces raw output when cs returns unparseable JSON", async () => {
- const stubDir = await mkdtempP(join(tmpdir(), "dispatch-cs-stub-"));
- try {
- process.env.DISPATCH_CS_BIN = writeStub(stubDir, ECHO_ENV_STUB);
- process.env.CS_STUB_OUTPUT = "this is not json";
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "x" });
- expect(out).toMatch(/^Error:/);
- expect(out).toContain("could not parse cs output");
- expect(out).toContain("this is not json");
- } finally {
- await rmP(stubDir, { recursive: true, force: true });
- }
- });
-
- it("reports an error (not 'No matches') when cs exits non-zero", async () => {
- const stubDir = await mkdtempP(join(tmpdir(), "dispatch-cs-stub-"));
- try {
- process.env.DISPATCH_CS_BIN = writeStub(stubDir, FAIL_STUB);
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "x" });
- expect(out).toMatch(/^Error:/);
- expect(out).toContain("exited with code 3");
- // stderr from cs is surfaced to the caller.
- expect(out).toContain("simulated failure on stderr");
- expect(out).not.toContain("No matches found");
- } finally {
- await rmP(stubDir, { recursive: true, force: true });
- }
- });
-
- // ── Live integration: only runs when a real `cs` binary is available. ──
- const liveCsBin = findRealCs();
- describe.runIf(liveCsBin)("live cs binary", () => {
- it("finds a real match and ranks the defining file", async () => {
- process.env.DISPATCH_CS_BIN = liveCsBin as string;
- // Seed a small tree with a clear match.
- await writeFileP(
- join(workDir, "alpha.ts"),
- "export function findTheNeedle() {\n return 42;\n}\n",
- );
- await writeFileP(join(workDir, "beta.ts"), "const x = 1;\n// nothing relevant here\n");
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "findTheNeedle" });
- expect(out).toContain("alpha.ts");
- expect(out).toContain("findTheNeedle");
- expect(out).not.toContain("Error:");
- });
-
- it("treats a dash-leading query as a search term, not a cs flag", async () => {
- process.env.DISPATCH_CS_BIN = liveCsBin as string;
- // A literal token beginning with '-' must not be parsed as a flag.
- await writeFileP(join(workDir, "dash.ts"), "const dashToken = 1;\n");
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "-dashToken" });
- // Whether or not cs ranks a hit, it must NOT error out on flag parsing.
- expect(out).not.toContain("unknown shorthand flag");
- expect(out).not.toMatch(/^Error: cs exited/);
- });
-
- it("renders snippet lines for prose (markdown) matches", async () => {
- process.env.DISPATCH_CS_BIN = liveCsBin as string;
- await writeFileP(
- join(workDir, "doc.md"),
- "# Title\n\nThis paragraph mentions widgetronics in prose.\n",
- );
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "widgetronics" });
- expect(out).toContain("doc.md");
- // The matching prose text must be shown, not just a bare header.
- expect(out).toContain("widgetronics");
- expect(out).not.toContain("no snippet available");
- });
-
- it("widens the snippet window when context is given", async () => {
- process.env.DISPATCH_CS_BIN = liveCsBin as string;
- const body = Array.from({ length: 21 }, (_, i) => `line ${i + 1}`);
- body[10] = "const findContextTarget = 1;";
- await writeFileP(join(workDir, "ctx.ts"), `${body.join("\n")}\n`);
- const tool = createSearchCodeTool(workDir);
- const countSnippetLines = (s: string) =>
- s.split("\n").filter((l) => /^\s+>?\s*\d+:/.test(l)).length;
- const narrow = await tool.execute({
- query: "findContextTarget",
- context: 0,
- result_limit: 1,
- });
- const wide = await tool.execute({
- query: "findContextTarget",
- context: 6,
- result_limit: 1,
- });
- expect(countSnippetLines(wide)).toBeGreaterThan(countSnippetLines(narrow));
- });
-
- it("returns 'No matches found.' for a query with no hits", async () => {
- process.env.DISPATCH_CS_BIN = liveCsBin as string;
- await writeFileP(join(workDir, "alpha.ts"), "export const a = 1;\n");
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "zzz_nonexistent_token_qqq" });
- expect(out).toBe("No matches found.");
- });
-
- it("tags .luau files as Luau", async () => {
- process.env.DISPATCH_CS_BIN = liveCsBin as string;
- await writeFileP(join(workDir, "mod.luau"), "function Mod.doThing()\n\treturn 1\nend\n");
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "doThing" });
- expect(out).toContain("mod.luau");
- expect(out).toContain("[Luau]");
- });
- });
-
- // ── Luau declaration detection: needs a cs built with the Luau patch
- // (docker/cs/luau-declarations.patch). Skipped on an unpatched/older cs. ──
- const luauCsBin = findLuauCapableCs(liveCsBin);
- describe.runIf(luauCsBin)("live cs binary (Luau declaration patch)", () => {
- // A small Luau module exercising every declaration form the patch adds.
- const LUAU_MODULE = [
- "local Mod = {}",
- "",
- "export type StuntResult = {",
- "\tscore: number,",
- "}",
- "",
- "type LaunchConfig = StuntResult",
- "",
- "function Mod.getDefaults(): LaunchConfig",
- "\treturn { score = 0 }",
- "end",
- "",
- "local function helperThing(x: number): number",
- "\treturn x + 1",
- "end",
- "",
- "Mod.live = Mod.getDefaults()",
- "local used = helperThing(1)",
- "",
- ].join("\n");
-
- beforeEach(async () => {
- process.env.DISPATCH_CS_BIN = luauCsBin as string;
- await writeFileP(join(workDir, "Mod.luau"), LUAU_MODULE);
- });
-
- it("detects `function Mod.x` declarations in .luau files", async () => {
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "getDefaults", only: "declarations" });
- expect(out).toContain("Mod.luau");
- expect(out).toContain("function Mod.getDefaults");
- expect(out).not.toContain("No matches found");
- });
-
- it("detects `local function` declarations in .luau files", async () => {
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "helperThing", only: "declarations" });
- expect(out).toContain("Mod.luau");
- expect(out).toContain("local function helperThing");
- });
-
- it("detects `type` / `export type` declarations in .luau files", async () => {
- const tool = createSearchCodeTool(workDir);
- const exportType = await tool.execute({ query: "StuntResult", only: "declarations" });
- expect(exportType).toContain("export type StuntResult");
- const aliasType = await tool.execute({ query: "LaunchConfig", only: "declarations" });
- expect(aliasType).toContain("type LaunchConfig");
- });
-
- it("excludes declaration lines when only=usages", async () => {
- const tool = createSearchCodeTool(workDir);
- const out = await tool.execute({ query: "getDefaults", only: "usages" });
- // The call site is a usage; the `function Mod.getDefaults` definition is not.
- expect(out).toContain("Mod.live = Mod.getDefaults()");
- expect(out).not.toContain("function Mod.getDefaults");
- });
- });
-
- // ── Fuzzy mid-word matching: needs a cs built with the fuzzy patch
- // (docker/cs/fuzzy-distance.patch). Skipped on an unpatched/older cs. ──
- const fuzzyCsBin = findFuzzyCapableCs(liveCsBin);
- describe.runIf(fuzzyCsBin)("live cs binary (fuzzy edit-distance patch)", () => {
- beforeEach(() => {
- process.env.DISPATCH_CS_BIN = fuzzyCsBin as string;
- });
-
- it("matches a mid-word deletion within distance 1", async () => {
- await writeFileP(
- join(workDir, "phys.ts"),
- "export function computeSlipAngle() {\n\treturn 0;\n}\n",
- );
- const tool = createSearchCodeTool(workDir);
- // "computSlipAngle" drops the 'e' mid-word — edit distance 1.
- const out = await tool.execute({ query: "computSlipAngle~1" });
- expect(out).toContain("phys.ts");
- expect(out).toContain("computeSlipAngle");
- expect(out).not.toBe("No matches found.");
- });
-
- it("matches a mid-word insertion within distance 1", async () => {
- await writeFileP(join(workDir, "tire.ts"), "const tireFriction = 1;\n");
- const tool = createSearchCodeTool(workDir);
- // "tireFricction" has an extra 'c' — edit distance 1.
- const out = await tool.execute({ query: "tireFricction~1" });
- expect(out).toContain("tire.ts");
- expect(out).toContain("tireFriction");
- });
- });
-});
-
-/**
- * Locate a usable `cs` binary for live tests. Honors DISPATCH_CS_TEST_BIN, then
- * a `cs` on PATH. Returns null when none is runnable, so the live suite is
- * skipped rather than failing in environments without cs.
- */
-function findRealCs(): string | null {
- const candidates = [process.env.DISPATCH_CS_TEST_BIN, "cs"].filter(Boolean) as string[];
- for (const bin of candidates) {
- try {
- const res = spawnSync(bin, ["--version"], { stdio: "ignore" });
- if (res.status === 0) return bin;
- } catch {
- // try next
- }
- }
- return null;
-}
-
-/**
- * Probe a `cs` binary against a throwaway corpus and return its trimmed stdout
- * (or "" on any failure). Used by the capability gates below so patch-dependent
- * live tests run only on a cs that actually has the patch — and skip (not fail)
- * on an unpatched/older binary.
- */
-function probeCs(bin: string, files: Record<string, string>, args: string[]): string {
- let dir: string | undefined;
- try {
- dir = mkdtempSync(join(tmpdir(), "dispatch-cs-probe-"));
- for (const [name, body] of Object.entries(files)) {
- writeFileSync(join(dir, name), body);
- }
- const res = spawnSync(bin, ["-f", "json", "--dir", dir, ...args], {
- encoding: "utf8",
- });
- if (res.status !== 0 || !res.stdout) return "";
- return res.stdout.trim();
- } catch {
- return "";
- } finally {
- if (dir) rmSync(dir, { recursive: true, force: true });
- }
-}
-
-/**
- * Return the cs binary only if it recognises Luau declarations (i.e. was built
- * with docker/cs/luau-declarations.patch): a `--only-declarations` search for a
- * top-level `function` in a .luau file yields a result. Otherwise null → skip.
- */
-function findLuauCapableCs(bin: string | null): string | null {
- if (!bin) return null;
- const out = probeCs(bin, { "probe.luau": "function Probe.thing()\n\treturn 1\nend\n" }, [
- "--only-declarations",
- "--",
- "thing",
- ]);
- return out !== "" && out !== "null" ? bin : null;
-}
-
-/**
- * Return the cs binary only if its fuzzy matcher honours mid-word edits (i.e.
- * was built with docker/cs/fuzzy-distance.patch): a distance-1 deletion matches.
- * Otherwise null → skip.
- */
-function findFuzzyCapableCs(bin: string | null): string | null {
- if (!bin) return null;
- const out = probeCs(bin, { "probe.txt": "const x = computeSlipAngle;\n" }, [
- "--",
- "computSlipAngle~1",
- ]);
- return out !== "" && out !== "null" ? bin : null;
-}
diff --git a/packages/core/tests/tools/send-to-tab.test.ts b/packages/core/tests/tools/send-to-tab.test.ts
deleted file mode 100644
index 21d8032..0000000
--- a/packages/core/tests/tools/send-to-tab.test.ts
+++ /dev/null
@@ -1,185 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import {
- createSendToTabTool,
- type SendToTabCallbacks,
- type TabResolution,
-} from "../../src/tools/send-to-tab.js";
-
-function makeCallbacks(overrides: Partial<SendToTabCallbacks> = {}): SendToTabCallbacks {
- return {
- resolveShortId: (): TabResolution => ({
- status: "ok",
- tab: { id: "target-id", title: "Target", handle: "targ" },
- }),
- deliver: () => ({ status: "started" }),
- listOpenHandles: () => [{ handle: "targ", title: "Target" }],
- self: { id: "self-id", handle: "self" },
- canReadTab: true,
- ...overrides,
- };
-}
-
-describe("createSendToTabTool — schema & description", () => {
- it("exposes tab_id and message params and a fire-and-forget description", () => {
- const tool = createSendToTabTool(makeCallbacks());
- expect(tool.name).toBe("send_to_tab");
- expect(tool.description).toContain("fire-and-forget");
- expect(tool.description.toLowerCase()).toContain("queued");
- // Description must steer the model away from busy-waiting for a reply.
- expect(tool.description.toLowerCase()).toContain("do not sleep");
- expect(tool.description.toLowerCase()).toContain("end your turn");
- });
-
- it("mentions read_tab in the description only when canReadTab is true", () => {
- const tool = createSendToTabTool(makeCallbacks({ canReadTab: true }));
- expect(tool.description).toContain("read_tab");
- });
-
- it("never mentions read_tab in the description when canReadTab is false", () => {
- const tool = createSendToTabTool(makeCallbacks({ canReadTab: false }));
- expect(tool.description).not.toContain("read_tab");
- // Still tells the agent a reply will wake it + to end its turn.
- expect(tool.description.toLowerCase()).toContain("wake you with a new message");
- expect(tool.description.toLowerCase()).toContain("end your turn");
- });
-});
-
-describe("createSendToTabTool — execute()", () => {
- it("delivers to a resolved target and reports the started status", async () => {
- const deliver = vi.fn(() => ({ status: "started" as const }));
- const tool = createSendToTabTool(makeCallbacks({ deliver }));
- const out = await tool.execute({ tab_id: "targ", message: "hello there" });
- expect(deliver).toHaveBeenCalledTimes(1);
- const [targetId, delivered] = deliver.mock.calls[0] ?? [];
- expect(targetId).toBe("target-id");
- // Provenance header names the sending tab's handle and marks it as a
- // peer agent (not the recipient's own user).
- expect(delivered).toContain("[message from tab self");
- expect(delivered).toContain("another agent");
- expect(delivered).toContain("hello there");
- // Reply contract: the recipient must answer via send_to_tab back to the
- // sender's handle, not as a plain text reply to its own user.
- expect(delivered).toContain('send_to_tab tool with tab_id "self"');
- expect(delivered).toContain("ONLY reply if");
- expect(out).toContain("idle");
- expect(out).toContain("targ");
- // Sender is steered away from busy-waiting and told to end its turn.
- expect(out.toLowerCase()).toContain("do not sleep");
- expect(out.toLowerCase()).toContain("end your turn");
- });
-
- it("points the sender at read_tab in the result only when canReadTab is true", async () => {
- const deliver = vi.fn(() => ({ status: "started" as const }));
- const tool = createSendToTabTool(makeCallbacks({ deliver, canReadTab: true }));
- const out = await tool.execute({ tab_id: "targ", message: "hi" });
- expect(out).toContain("read_tab");
- });
-
- it("omits read_tab from the result when canReadTab is false", async () => {
- const deliver = vi.fn(() => ({ status: "started" as const }));
- const tool = createSendToTabTool(makeCallbacks({ deliver, canReadTab: false }));
- const out = await tool.execute({ tab_id: "targ", message: "hi" });
- expect(out).not.toContain("read_tab");
- // Still steers away from busy-waiting and toward ending the turn.
- expect(out.toLowerCase()).toContain("do not sleep");
- expect(out.toLowerCase()).toContain("end your turn");
- });
-
- it("reports the queued status when the target is busy", async () => {
- const deliver = vi.fn(() => ({ status: "queued" as const }));
- const tool = createSendToTabTool(makeCallbacks({ deliver }));
- const out = await tool.execute({ tab_id: "targ", message: "ping" });
- expect(out.toLowerCase()).toContain("queued");
- expect(out.toLowerCase()).toContain("busy");
- });
-
- it("reports a HELD message when delivery is suppressed (auto-wake limit hit)", async () => {
- const deliver = vi.fn(() => ({ status: "suppressed" as const }));
- const tool = createSendToTabTool(makeCallbacks({ deliver }));
- const out = await tool.execute({ tab_id: "targ", message: "ping again" });
- expect(out).toContain("HELD");
- expect(out.toLowerCase()).toContain("limit");
- // It must steer the sender away from retrying in a loop.
- expect(out.toLowerCase()).toContain("do not keep resending");
- expect(out.toLowerCase()).toContain("human");
- });
-
- it("rejects an empty tab_id and lists open handles", async () => {
- const tool = createSendToTabTool(makeCallbacks());
- const out = await tool.execute({ tab_id: " ", message: "hi" });
- expect(out).toContain("Error");
- expect(out).toContain("targ");
- });
-
- it("rejects an empty message", async () => {
- const deliver = vi.fn(() => ({ status: "started" as const }));
- const tool = createSendToTabTool(makeCallbacks({ deliver }));
- const out = await tool.execute({ tab_id: "targ", message: " " });
- expect(out).toContain("Error");
- expect(deliver).not.toHaveBeenCalled();
- });
-
- it("returns a helpful error and open-tab list when the id is unknown", async () => {
- const deliver = vi.fn(() => ({ status: "started" as const }));
- const tool = createSendToTabTool(
- makeCallbacks({
- resolveShortId: () => ({ status: "none" }),
- deliver,
- }),
- );
- const out = await tool.execute({ tab_id: "zzzz", message: "hi" });
- expect(out).toContain("no open tab matches");
- expect(out).toContain("Currently open tabs:");
- expect(deliver).not.toHaveBeenCalled();
- });
-
- it("asks for more characters when the id is ambiguous", async () => {
- const deliver = vi.fn(() => ({ status: "started" as const }));
- const tool = createSendToTabTool(
- makeCallbacks({
- resolveShortId: () => ({
- status: "ambiguous",
- matches: [
- { id: "a1", title: "One", handle: "abcd1" },
- { id: "a2", title: "Two", handle: "abcd2" },
- ],
- }),
- deliver,
- }),
- );
- const out = await tool.execute({ tab_id: "abcd", message: "hi" });
- expect(out).toContain("ambiguous");
- expect(out).toContain("abcd1");
- expect(out).toContain("abcd2");
- expect(deliver).not.toHaveBeenCalled();
- });
-
- it("refuses to send to its own tab", async () => {
- const deliver = vi.fn(() => ({ status: "started" as const }));
- const tool = createSendToTabTool(
- makeCallbacks({
- resolveShortId: () => ({
- status: "ok",
- tab: { id: "self-id", title: "Me", handle: "self" },
- }),
- deliver,
- }),
- );
- const out = await tool.execute({ tab_id: "self", message: "hi" });
- expect(out).toContain("cannot send a message to your own tab");
- expect(deliver).not.toHaveBeenCalled();
- });
-
- it("surfaces a thrown delivery error instead of crashing", async () => {
- const tool = createSendToTabTool(
- makeCallbacks({
- deliver: () => {
- throw new Error("boom");
- },
- }),
- );
- const out = await tool.execute({ tab_id: "targ", message: "hi" });
- expect(out).toContain("Error delivering message");
- expect(out).toContain("boom");
- });
-});
diff --git a/packages/core/tests/tools/summon.test.ts b/packages/core/tests/tools/summon.test.ts
deleted file mode 100644
index 4885a94..0000000
--- a/packages/core/tests/tools/summon.test.ts
+++ /dev/null
@@ -1,349 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import {
- type AvailableAgent,
- createSummonTool,
- type SummonCallbacks,
-} from "../../src/tools/summon.js";
-
-const noopCallbacks: SummonCallbacks = {
- spawn: async () => "agent-id-stub",
- getResult: async () => ({ status: "done", result: "" }),
-};
-
-describe("createSummonTool — description content", () => {
- it("lists the agent directories so the LLM knows where to look", () => {
- const tool = createSummonTool(
- "/tmp/work",
- noopCallbacks,
- [],
- [],
- ["/home/u/.config/dispatch/agents", "/tmp/work/.dispatch/agents"],
- );
- expect(tool.description).toContain("/home/u/.config/dispatch/agents");
- expect(tool.description).toContain("/tmp/work/.dispatch/agents");
- expect(tool.description).toContain("read_file");
- });
-
- it("includes available agent slugs+names in the description", () => {
- const agents: AvailableAgent[] = [
- {
- slug: "programmer",
- name: "Programmer",
- description: "Implements code from a plan.",
- path: "/home/u/.config/dispatch/agents/programmer.toml",
- },
- {
- slug: "researcher",
- name: "Researcher",
- description: "Investigates topics.",
- path: "/home/u/.config/dispatch/agents/researcher.toml",
- },
- ];
- const tool = createSummonTool(
- "/tmp/work",
- noopCallbacks,
- agents,
- [],
- ["/home/u/.config/dispatch/agents"],
- );
- expect(tool.description).toContain("programmer");
- expect(tool.description).toContain("Programmer");
- expect(tool.description).toContain("Implements code from a plan");
- expect(tool.description).toContain("researcher");
- expect(tool.description).toContain("Investigates topics");
- });
-
- it("emits a 'no agents defined' notice when the catalog is empty", () => {
- const tool = createSummonTool(
- "/tmp/work",
- noopCallbacks,
- [],
- [],
- ["/home/u/.config/dispatch/agents"],
- );
- expect(tool.description).toContain("No agent definitions are currently defined");
- });
-
- it("shows two groups when userAgentEnabled is true", () => {
- const subagents: AvailableAgent[] = [
- {
- slug: "programmer",
- name: "Programmer",
- description: "Codes things",
- path: "/agents/programmer.toml",
- },
- ];
- const userAgents: AvailableAgent[] = [
- {
- slug: "default",
- name: "Default",
- description: "Default agent",
- path: "/agents/default.toml",
- },
- ];
- const tool = createSummonTool(
- "/tmp/work",
- noopCallbacks,
- subagents,
- userAgents,
- ["/agents"],
- true,
- );
- expect(tool.description).toContain("Subagents (spawned as child tabs):");
- expect(tool.description).toContain(
- "User agents (spawned as independent top-level tabs, requires top_level=true):",
- );
- expect(tool.description).toContain("programmer");
- expect(tool.description).toContain("default");
- });
-
- it("hides user agents group when userAgentEnabled is false", () => {
- const subagents: AvailableAgent[] = [
- {
- slug: "programmer",
- name: "Programmer",
- description: "Codes things",
- path: "/agents/programmer.toml",
- },
- ];
- const userAgents: AvailableAgent[] = [
- {
- slug: "default",
- name: "Default",
- description: "Default agent",
- path: "/agents/default.toml",
- },
- ];
- const tool = createSummonTool(
- "/tmp/work",
- noopCallbacks,
- subagents,
- userAgents,
- ["/agents"],
- false,
- );
- expect(tool.description).toContain("Available agents:");
- expect(tool.description).not.toContain("User agents");
- // "default" appears in generic description text, so check for the slug listing format
- expect(tool.description).not.toContain("- default: Default");
- });
-});
-
-describe("createSummonTool — execute() argument forwarding", () => {
- it("forwards agent slug through to callbacks.spawn", async () => {
- const spawn = vi.fn(async () => "tab-xyz");
- const tool = createSummonTool(
- "/tmp/work",
- { spawn, getResult: async () => ({ status: "done", result: "ok" }) },
- [],
- [],
- );
- await tool.execute({
- task: "do thing",
- agent: "programmer",
- background: true,
- });
- expect(spawn).toHaveBeenCalledTimes(1);
- const callArg = spawn.mock.calls[0]?.[0];
- expect(callArg).toMatchObject({
- task: "do thing",
- agentSlug: "programmer",
- });
- });
-
- it("returns spawned agent_id when background=true (no blocking on result)", async () => {
- const getResult = vi.fn(async () => ({ status: "done" as const, result: "should-not-see" }));
- const tool = createSummonTool("/tmp/work", { spawn: async () => "id-42", getResult }, [], []);
- const out = await tool.execute({ task: "x", agent: "test-agent", background: true });
- expect(out).toContain("id-42");
- // Background mode must not block on getResult
- expect(getResult).not.toHaveBeenCalled();
- });
-
- it("blocks on result and returns it when background=false (default)", async () => {
- const tool = createSummonTool(
- "/tmp/work",
- {
- spawn: async () => "id-1",
- getResult: async () => ({ status: "done", result: "child-output" }),
- },
- [],
- [],
- );
- const out = await tool.execute({ task: "x", agent: "test-agent" });
- // Foreground summons prefix the blocked result with `agent_id: <id>` so
- // the frontend's ToolCallDisplay regex can surface the "Open Tab" button
- // (see summon.ts). Assert both the prefix and the child output survive.
- expect(out).toContain("agent_id: id-1");
- expect(out).toBe("agent_id: id-1\n\nchild-output");
- });
-
- it("surfaces child errors when blocking", async () => {
- const tool = createSummonTool(
- "/tmp/work",
- {
- spawn: async () => "id-1",
- getResult: async () => ({ status: "error", error: "boom" }),
- },
- [],
- [],
- );
- const out = await tool.execute({ task: "x", agent: "test-agent" });
- expect(out).toContain("boom");
- });
-
- it("returns fire-and-forget message when top_level=true", async () => {
- const spawn = vi.fn(async () => "ua-tab-1");
- const getResult = vi.fn(async () => ({ status: "done" as const, result: "nope" }));
- const tool = createSummonTool(
- "/tmp/work",
- { spawn, getResult },
- [],
- [],
- [],
- true, // userAgentEnabled
- );
- const out = await tool.execute({
- task: "do stuff",
- agent: "default",
- top_level: true,
- });
- expect(out).toContain("User agent spawned successfully");
- expect(out).toContain("ua-tab-1");
- expect(out).toContain("fire-and-forget");
- expect(getResult).not.toHaveBeenCalled();
-
- // Verify topLevel was forwarded to spawn
- const callArg = spawn.mock.calls[0]?.[0];
- expect(callArg).toMatchObject({ topLevel: true });
- });
-
- it("ignores top_level when userAgentEnabled is false", async () => {
- const spawn = vi.fn(async () => "tab-1");
- const getResult = vi.fn(async () => ({ status: "done" as const, result: "result" }));
- const tool = createSummonTool(
- "/tmp/work",
- { spawn, getResult },
- [],
- [],
- [],
- false, // userAgentEnabled
- );
- const out = await tool.execute({
- task: "do stuff",
- agent: "default",
- top_level: true, // should be ignored
- });
- // Should behave as a normal foreground summon, not fire-and-forget
- expect(out).not.toContain("fire-and-forget");
- expect(getResult).toHaveBeenCalled();
- });
-});
-
-describe("createSummonTool — user-agent-only mode (perm_user_agent without perm_summon)", () => {
- // userAgentEnabled=true, subagentEnabled=false → the tool spawns ONLY
- // top-level user agents. `top_level` is implied (and forced), the
- // subagent/parallel-work prose is dropped, and only the user-agent
- // catalog group is shown.
- const subagents: AvailableAgent[] = [
- {
- slug: "programmer",
- name: "Programmer",
- description: "Codes things",
- path: "/agents/programmer.toml",
- },
- ];
- const userAgents: AvailableAgent[] = [
- {
- slug: "default",
- name: "Default",
- description: "Default agent",
- path: "/agents/default.toml",
- },
- ];
-
- function userAgentOnlyTool(
- spawn = vi.fn(async () => "ua-1"),
- getResult = vi.fn(async () => ({ status: "done" as const, result: "nope" })),
- ) {
- return {
- spawn,
- getResult,
- tool: createSummonTool(
- "/tmp/work",
- { spawn, getResult },
- subagents,
- userAgents,
- ["/agents"],
- true, // userAgentEnabled
- false, // subagentEnabled
- ),
- };
- }
-
- it("describes spawning user agents and omits subagent/parallel-work prose", () => {
- const { tool } = userAgentOnlyTool();
- expect(tool.description).toContain("Spawn an independent top-level user agent");
- expect(tool.description).toContain("fire-and-forget");
- expect(tool.description).not.toContain("Pattern for parallel work");
- expect(tool.description).not.toContain("Set background=true");
- });
-
- it("lists only the user-agent catalog group, not subagents", () => {
- const { tool } = userAgentOnlyTool();
- expect(tool.description).toContain("User agents (spawned as independent top-level tabs):");
- expect(tool.description).toContain("default");
- // Subagents must not be advertised in user-agent-only mode.
- expect(tool.description).not.toContain("Subagents (spawned as child tabs):");
- expect(tool.description).not.toContain("- programmer: Programmer");
- });
-
- it("only lists user-agent slugs in the 'agent' parameter description", () => {
- const { tool } = userAgentOnlyTool();
- const agentParam = (tool.parameters as unknown as { shape: { agent: { description: string } } })
- .shape.agent;
- expect(agentParam.description).toContain("default");
- expect(agentParam.description).not.toContain("programmer");
- });
-
- it("omits the top_level parameter (it is implied)", () => {
- const { tool } = userAgentOnlyTool();
- const shape = (tool.parameters as unknown as { shape: Record<string, unknown> }).shape;
- expect("top_level" in shape).toBe(false);
- });
-
- it("omits the background parameter (user agents are fire-and-forget)", () => {
- const { tool } = userAgentOnlyTool();
- const shape = (tool.parameters as unknown as { shape: Record<string, unknown> }).shape;
- expect("background" in shape).toBe(false);
- });
-
- it("forces topLevel=true on spawn even when top_level is not passed", async () => {
- const spawn = vi.fn(async () => "ua-99");
- const getResult = vi.fn(async () => ({ status: "done" as const, result: "nope" }));
- const { tool } = userAgentOnlyTool(spawn, getResult);
- const out = await tool.execute({ task: "do stuff", agent: "default" });
- expect(out).toContain("User agent spawned successfully");
- expect(out).toContain("ua-99");
- expect(out).toContain("fire-and-forget");
- // Never blocks on a result for fire-and-forget user agents.
- expect(getResult).not.toHaveBeenCalled();
- const callArg = spawn.mock.calls[0]?.[0];
- expect(callArg).toMatchObject({ topLevel: true, agentSlug: "default" });
- });
-});
-
-describe("createSummonTool — subagentEnabled defaults preserve legacy behavior", () => {
- it("defaults subagentEnabled=true so omitting it keeps subagent spawning", async () => {
- const spawn = vi.fn(async () => "tab-1");
- const getResult = vi.fn(async () => ({ status: "done" as const, result: "child" }));
- // No userAgentEnabled/subagentEnabled args → legacy subagent-only mode.
- const tool = createSummonTool("/tmp/work", { spawn, getResult }, [], []);
- const out = await tool.execute({ task: "x", agent: "programmer" });
- // Foreground subagent summon blocks and returns the child result.
- expect(out).toBe("agent_id: tab-1\n\nchild");
- expect(getResult).toHaveBeenCalled();
- const callArg = spawn.mock.calls[0]?.[0];
- expect(callArg).not.toHaveProperty("topLevel");
- });
-});
diff --git a/packages/core/tests/tools/task-list.test.ts b/packages/core/tests/tools/task-list.test.ts
deleted file mode 100644
index 5903fec..0000000
--- a/packages/core/tests/tools/task-list.test.ts
+++ /dev/null
@@ -1,158 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import { createTaskListTool, TaskList } from "../../src/tools/task-list.js";
-import type { TaskItem } from "../../src/types/index.js";
-
-describe("TaskList (declarative store)", () => {
- it("starts empty", () => {
- const list = new TaskList();
- expect(list.getTasks()).toEqual([]);
- });
-
- it("setTasks replaces the whole list and assigns positional ids", () => {
- const list = new TaskList();
- const result = list.setTasks([
- { content: "first", status: "in_progress" },
- { content: "second", status: "pending" },
- ]);
- expect(result).toEqual([
- { id: "task-1", content: "first", status: "in_progress" },
- { id: "task-2", content: "second", status: "pending" },
- ]);
- expect(list.getTasks()).toEqual(result);
- });
-
- it("a second setTasks fully replaces the previous list (no append)", () => {
- const list = new TaskList();
- list.setTasks([
- { content: "a", status: "completed" },
- { content: "b", status: "completed" },
- { content: "c", status: "pending" },
- ]);
- const next = list.setTasks([{ content: "only", status: "in_progress" }]);
- expect(next).toEqual([{ id: "task-1", content: "only", status: "in_progress" }]);
- expect(list.getTasks()).toHaveLength(1);
- });
-
- it("preserves all four statuses", () => {
- const list = new TaskList();
- const result = list.setTasks([
- { content: "p", status: "pending" },
- { content: "i", status: "in_progress" },
- { content: "c", status: "completed" },
- { content: "x", status: "cancelled" },
- ]);
- expect(result.map((t) => t.status)).toEqual([
- "pending",
- "in_progress",
- "completed",
- "cancelled",
- ]);
- });
-
- it("defaults missing/invalid status to pending", () => {
- const list = new TaskList();
- const result = list.setTasks([
- { content: "no status" },
- { content: "bogus", status: "done" },
- { content: "junk", status: 42 },
- ]);
- expect(result.map((t) => t.status)).toEqual(["pending", "pending", "pending"]);
- });
-
- it("an empty array clears the list", () => {
- const list = new TaskList();
- list.setTasks([{ content: "x", status: "pending" }]);
- expect(list.setTasks([])).toEqual([]);
- expect(list.getTasks()).toEqual([]);
- });
-
- it("getTasks returns copies (no external mutation leaks in)", () => {
- const list = new TaskList();
- list.setTasks([{ content: "x", status: "pending" }]);
- const snapshot = list.getTasks();
- snapshot[0].content = "mutated";
- expect(list.getTasks()[0].content).toBe("x");
- });
-
- it("onChange fires on every setTasks with the new snapshot", () => {
- const list = new TaskList();
- const seen: TaskItem[][] = [];
- const unsubscribe = list.onChange((tasks) => seen.push(tasks));
- list.setTasks([{ content: "a", status: "pending" }]);
- list.setTasks([{ content: "b", status: "completed" }]);
- expect(seen).toHaveLength(2);
- expect(seen[0]).toEqual([{ id: "task-1", content: "a", status: "pending" }]);
- expect(seen[1]).toEqual([{ id: "task-1", content: "b", status: "completed" }]);
- unsubscribe();
- list.setTasks([{ content: "c", status: "pending" }]);
- expect(seen).toHaveLength(2);
- });
-});
-
-describe("createTaskListTool", () => {
- it("exposes a single declarative `todos` parameter and the name `todo`", () => {
- const tool = createTaskListTool(new TaskList());
- expect(tool.name).toBe("todo");
- // One top-level param: the whole-list `todos` array.
- const shape = (tool.parameters as { shape: Record<string, unknown> }).shape;
- expect(Object.keys(shape)).toEqual(["todos"]);
- });
-
- it("execute updates the store and echoes the list WITHOUT ids", async () => {
- const list = new TaskList();
- const tool = createTaskListTool(list);
- const out = await tool.execute({
- todos: [
- { content: "plan", status: "completed" },
- { content: "build", status: "in_progress" },
- ],
- });
- expect(JSON.parse(out)).toEqual([
- { content: "plan", status: "completed" },
- { content: "build", status: "in_progress" },
- ]);
- // Store has ids; the echo does not.
- expect(list.getTasks()).toEqual([
- { id: "task-1", content: "plan", status: "completed" },
- { id: "task-2", content: "build", status: "in_progress" },
- ]);
- });
-
- it("execute fires onChange so the UI broadcast is wired", async () => {
- const list = new TaskList();
- const cb = vi.fn();
- list.onChange(cb);
- const tool = createTaskListTool(list);
- await tool.execute({ todos: [{ content: "x", status: "pending" }] });
- expect(cb).toHaveBeenCalledTimes(1);
- });
-
- it("execute with an empty array clears the store", async () => {
- const list = new TaskList();
- list.setTasks([{ content: "x", status: "pending" }]);
- const tool = createTaskListTool(list);
- const out = await tool.execute({ todos: [] });
- expect(JSON.parse(out)).toEqual([]);
- expect(list.getTasks()).toEqual([]);
- });
-
- it("execute defaults invalid status to pending in both store and echo", async () => {
- const list = new TaskList();
- const tool = createTaskListTool(list);
- const out = await tool.execute({ todos: [{ content: "x", status: "done" }] });
- expect(JSON.parse(out)).toEqual([{ content: "x", status: "pending" }]);
- expect(list.getTasks()[0].status).toBe("pending");
- });
-
- it("execute rejects a non-array todos param", async () => {
- const tool = createTaskListTool(new TaskList());
- const out = await tool.execute({ todos: "nope" });
- expect(out).toMatch(/Error/);
- });
-
- it("execute rejects items missing a content string", async () => {
- const tool = createTaskListTool(new TaskList());
- const out = await tool.execute({ todos: [{ status: "pending" }] });
- expect(out).toMatch(/Error/);
- });
-});
diff --git a/packages/core/tests/tools/write-file.test.ts b/packages/core/tests/tools/write-file.test.ts
deleted file mode 100644
index 0dedbfc..0000000
--- a/packages/core/tests/tools/write-file.test.ts
+++ /dev/null
@@ -1,152 +0,0 @@
-import { access, mkdtemp, readdir, readFile, rm, symlink } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import { createWriteFileTool } from "../../src/tools/write-file.js";
-
-describe("write_file tool", () => {
- let workDir: string;
-
- beforeEach(async () => {
- workDir = await mkdtemp(join(tmpdir(), "dispatch-test-"));
- });
-
- afterEach(async () => {
- await rm(workDir, { recursive: true, force: true });
- });
-
- it("writes a new file", async () => {
- const tool = createWriteFileTool(workDir);
- const result = await tool.execute({
- path: "output.txt",
- content: "test content",
- });
- expect(result).toMatch(/successfully wrote/i);
- const written = await readFile(join(workDir, "output.txt"), "utf8");
- expect(written).toBe("test content");
- });
-
- it("creates parent directories", async () => {
- const tool = createWriteFileTool(workDir);
- const result = await tool.execute({
- path: "nested/dir/file.txt",
- content: "nested",
- });
- expect(result).toMatch(/successfully wrote/i);
- const written = await readFile(join(workDir, "nested/dir/file.txt"), "utf8");
- expect(written).toBe("nested");
- });
-
- it("blocks path traversal", async () => {
- const tool = createWriteFileTool(workDir);
- const result = await tool.execute({ path: "../evil.txt", content: "bad" });
- expect(result).toMatch(/outside the working directory/i);
- });
-
- // Regression for `resolve(join(workingDirectory, filePath))` — when filePath
- // is absolute, `join` does NOT short-circuit, it concatenates. The old code
- // silently rewrote `/etc/foo` to `<workdir>/etc/foo` and "succeeded" by
- // writing to the wrong location. After the fix, absolute paths resolve
- // to themselves and the workdir gate behaves correctly.
- describe("absolute path handling", () => {
- it("writes an absolute path that lives under the workdir to the expected location", async () => {
- const tool = createWriteFileTool(workDir);
- const absoluteTarget = join(workDir, "abs.txt");
- const result = await tool.execute({ path: absoluteTarget, content: "abs content" });
- expect(result).toMatch(/successfully wrote/i);
- // File must exist at exactly `absoluteTarget`, NOT at
- // `<workdir>/<workdir>/abs.txt` (the old mangled location).
- const written = await readFile(absoluteTarget, "utf8");
- expect(written).toBe("abs content");
- });
-
- it("rejects absolute paths outside the workdir instead of silently mangling them", async () => {
- const tool = createWriteFileTool(workDir);
- // Pick a path under tmpdir that's definitely not under workDir.
- // Under the bug, this got rewritten to `<workdir>/tmp/...` and the
- // write "succeeded" at the wrong location.
- const evilPath = join(tmpdir(), `dispatch-evil-${Date.now()}.txt`);
- const result = await tool.execute({ path: evilPath, content: "should not land" });
- expect(result).toMatch(/outside the working directory/i);
- });
- });
-
- // Symlink containment: even when the *leaf* doesn't exist yet (the
- // common case for write_file creating a new file), `canonicalize`
- // must walk up to the nearest existing ancestor and resolve symlinks
- // there. Otherwise, a directory symlink inside workdir pointing
- // outside lets a write escape the workspace.
- describe("symlink handling", () => {
- let externalDir: string;
-
- beforeEach(async () => {
- externalDir = await mkdtemp(join(tmpdir(), "dispatch-external-"));
- });
-
- afterEach(async () => {
- await rm(externalDir, { recursive: true, force: true });
- });
-
- it("blocks writes that escape through a parent symlink (leaf does not exist yet)", async () => {
- const tool = createWriteFileTool(workDir);
- // `escape` is a symlink *inside* workdir to a directory *outside*.
- await symlink(externalDir, join(workDir, "escape"));
- const result = await tool.execute({
- path: "escape/payload.txt",
- content: "malicious payload",
- });
- expect(result).toMatch(/outside the working directory/i);
- // And the file must NOT exist in externalDir.
- await expect(access(join(externalDir, "payload.txt"))).rejects.toThrow();
- // And externalDir should be empty (nothing leaked through).
- const entries = await readdir(externalDir);
- expect(entries).toEqual([]);
- });
- });
-
- describe("onAfterWrite hook", () => {
- it("appends the hook's returned string to a successful write", async () => {
- const tool = createWriteFileTool(workDir, async (abs) => `DIAGNOSTICS for ${abs}`);
- const result = await tool.execute({ path: "a.luau", content: "local x = 1" });
- expect(result).toMatch(/successfully wrote/i);
- expect(result).toContain("DIAGNOSTICS for");
- expect(result).toContain(join(workDir, "a.luau"));
- });
-
- it("does not append when the hook returns empty string", async () => {
- const tool = createWriteFileTool(workDir, async () => "");
- const result = await tool.execute({ path: "a.luau", content: "local x = 1" });
- expect(result.trim()).toMatch(/^Successfully wrote to "a\.luau"\.$/);
- });
-
- it("does not run the hook when the write is blocked (traversal)", async () => {
- let called = false;
- const tool = createWriteFileTool(workDir, async () => {
- called = true;
- return "should not appear";
- });
- const result = await tool.execute({ path: "../evil.txt", content: "bad" });
- expect(result).toMatch(/outside the working directory/i);
- expect(called).toBe(false);
- });
-
- it("swallows hook errors so a throwing hook never fails the write", async () => {
- const tool = createWriteFileTool(workDir, async () => {
- throw new Error("lsp blew up");
- });
- const result = await tool.execute({ path: "a.luau", content: "local x = 1" });
- expect(result).toMatch(/successfully wrote/i);
- expect(result).not.toContain("lsp blew up");
- });
-
- it("passes the canonical absolute path to the hook", async () => {
- let seen = "";
- const tool = createWriteFileTool(workDir, async (abs) => {
- seen = abs;
- return "";
- });
- await tool.execute({ path: "nested/b.luau", content: "x" });
- expect(seen).toBe(join(workDir, "nested/b.luau"));
- });
- });
-});
diff --git a/packages/core/tests/types/reasoning-effort.test.ts b/packages/core/tests/types/reasoning-effort.test.ts
deleted file mode 100644
index 97cd26c..0000000
--- a/packages/core/tests/types/reasoning-effort.test.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { describe, expect, it } from "vitest";
-import {
- DEFAULT_REASONING_EFFORT,
- isReasoningEffort,
- REASONING_EFFORT_LABELS,
- REASONING_EFFORTS,
-} from "../../src/types/index.js";
-
-describe("REASONING_EFFORTS — canonical effort list (single source of truth)", () => {
- it("is ordered least→most and includes xhigh between high and max", () => {
- expect(REASONING_EFFORTS).toEqual(["none", "low", "medium", "high", "xhigh", "max"]);
- const hi = REASONING_EFFORTS.indexOf("high");
- const xhi = REASONING_EFFORTS.indexOf("xhigh");
- const mx = REASONING_EFFORTS.indexOf("max");
- expect(hi).toBeLessThan(xhi);
- expect(xhi).toBeLessThan(mx);
- });
-
- it("has a human-readable label for every level (no gaps)", () => {
- for (const effort of REASONING_EFFORTS) {
- expect(REASONING_EFFORT_LABELS[effort]).toBeTruthy();
- }
- expect(Object.keys(REASONING_EFFORT_LABELS).sort()).toEqual([...REASONING_EFFORTS].sort());
- });
-
- it("defaults to high", () => {
- expect(DEFAULT_REASONING_EFFORT).toBe("high");
- expect(REASONING_EFFORTS).toContain(DEFAULT_REASONING_EFFORT);
- });
-});
-
-describe("isReasoningEffort", () => {
- it("accepts every canonical level", () => {
- for (const effort of REASONING_EFFORTS) {
- expect(isReasoningEffort(effort)).toBe(true);
- }
- });
-
- it("rejects unknown strings and non-strings", () => {
- expect(isReasoningEffort("turbo")).toBe(false);
- expect(isReasoningEffort("HIGH")).toBe(false);
- expect(isReasoningEffort("")).toBe(false);
- expect(isReasoningEffort(undefined)).toBe(false);
- expect(isReasoningEffort(null)).toBe(false);
- expect(isReasoningEffort(3)).toBe(false);
- expect(isReasoningEffort({})).toBe(false);
- });
-});
diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json
deleted file mode 100644
index 2d6fedd..0000000
--- a/packages/core/tsconfig.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
- "extends": "../../tsconfig.base.json",
- "compilerOptions": {
- "outDir": "dist",
- "rootDir": "src",
- "types": ["@types/bun"]
- },
- "include": ["src/**/*.ts"],
- "exclude": ["node_modules", "dist", "tests"]
-}
diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts
deleted file mode 100644
index ba60b3c..0000000
--- a/packages/core/vitest.config.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { defineConfig } from "vitest/config";
-
-export default defineConfig({
- test: {
- include: ["tests/**/*.test.ts"],
- server: {
- deps: {
- // Force inline resolution for packages that break under Bun's
- // .bun/ symlink layout in Docker environments
- inline: ["zod"],
- },
- },
- },
-});
diff --git a/packages/frontend/electron/main.cjs b/packages/frontend/electron/main.cjs
deleted file mode 100644
index 251e96c..0000000
--- a/packages/frontend/electron/main.cjs
+++ /dev/null
@@ -1,33 +0,0 @@
-const { app, BrowserWindow } = require("electron");
-const path = require("node:path");
-
-function createWindow() {
- const win = new BrowserWindow({
- width: 1280,
- height: 800,
- title: "Dispatch",
- webPreferences: {
- nodeIntegration: false,
- contextIsolation: true,
- preload: path.join(__dirname, "preload.cjs"),
- },
- });
-
- win.loadFile(path.join(__dirname, "../dist/index.html"));
-}
-
-app.whenReady().then(() => {
- createWindow();
-
- app.on("activate", () => {
- if (BrowserWindow.getAllWindows().length === 0) {
- createWindow();
- }
- });
-});
-
-app.on("window-all-closed", () => {
- if (process.platform !== "darwin") {
- app.quit();
- }
-});
diff --git a/packages/frontend/electron/preload.cjs b/packages/frontend/electron/preload.cjs
deleted file mode 100644
index 14ebb26..0000000
--- a/packages/frontend/electron/preload.cjs
+++ /dev/null
@@ -1,7 +0,0 @@
-const { contextBridge } = require("electron");
-
-contextBridge.exposeInMainWorld("versions", {
- electron: process.versions.electron,
- node: process.versions.node,
- chrome: process.versions.chrome,
-});
diff --git a/packages/frontend/index.html b/packages/frontend/index.html
deleted file mode 100644
index 32b56aa..0000000
--- a/packages/frontend/index.html
+++ /dev/null
@@ -1,12 +0,0 @@
-<!doctype html>
-<html lang="en">
- <head>
- <meta charset="UTF-8" />
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <title>Dispatch</title>
- </head>
- <body>
- <div id="app"></div>
- <script type="module" src="/src/main.ts"></script>
- </body>
-</html>
diff --git a/packages/frontend/package.json b/packages/frontend/package.json
deleted file mode 100644
index c4966e5..0000000
--- a/packages/frontend/package.json
+++ /dev/null
@@ -1,54 +0,0 @@
-{
- "name": "@dispatch/frontend",
- "version": "0.0.1",
- "private": true,
- "type": "module",
- "main": "electron/main.cjs",
- "scripts": {
- "dev": "vite",
- "build": "vite build",
- "preview": "vite preview",
- "serve": "bun serve.ts",
- "test": "vitest run",
- "test:watch": "vitest",
- "typecheck": "svelte-check --tsconfig ./tsconfig.json",
- "electron": "electron electron/main.cjs",
- "electron:dev": "vite build && electron electron/main.cjs",
- "dist:win": "vite build && electron-builder --win --dir"
- },
- "build": {
- "appId": "com.dispatch.app",
- "productName": "Dispatch",
- "directories": {
- "output": "release"
- },
- "files": [
- "dist/**/*",
- "electron/**/*.cjs"
- ],
- "win": {
- "target": "dir",
- "icon": "../packaging/dispatch.png"
- }
- },
- "dependencies": {
- "@dispatch/core": "workspace:*",
- "dompurify": "^3.4.5",
- "highlight.js": "^11.11.1",
- "marked": "^18.0.4",
- "marked-highlight": "^2.2.4",
- "phosphor-svelte": "^3.1.0",
- "svelte": "^5.0.0"
- },
- "devDependencies": {
- "@sveltejs/vite-plugin-svelte": "^5.0.0",
- "@tailwindcss/vite": "^4.0.0",
- "@types/dompurify": "^3.2.0",
- "daisyui": "^5.0.0",
- "electron": "^35.0.0",
- "electron-builder": "^26.0.0",
- "svelte-check": "^4.0.0",
- "tailwindcss": "^4.0.0",
- "vite": "^6.0.0"
- }
-}
diff --git a/packages/frontend/serve.ts b/packages/frontend/serve.ts
deleted file mode 100644
index 020b698..0000000
--- a/packages/frontend/serve.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-// Static file server for the built frontend (packages/frontend/dist).
-// Uses Bun's built-in HTTP server — no extra runtime dependencies.
-//
-// Environment variables:
-// PORT — port to listen on (default: 18391)
-// HOST — interface to bind to (default: 0.0.0.0)
-// DIST_DIR — absolute path to the static assets directory
-// (default: <this-file's-dir>/dist)
-//
-// SPA fallback: requests that don't match a file on disk fall back to
-// index.html so client-side routing works.
-
-import { existsSync, statSync } from "node:fs";
-import { join, normalize, resolve } from "node:path";
-import { fileURLToPath } from "node:url";
-
-const moduleDir = (() => {
- try {
- // @ts-expect-error — Bun provides import.meta.dir
- return import.meta.dir as string;
- } catch {
- return resolve(fileURLToPath(import.meta.url), "..");
- }
-})();
-
-const port = Number(process.env.PORT) || 18391;
-const host = process.env.HOST || "0.0.0.0";
-const distDir = resolve(process.env.DIST_DIR || join(moduleDir, "dist"));
-
-if (!existsSync(distDir) || !statSync(distDir).isDirectory()) {
- console.error(`[dispatch-frontend] dist directory not found: ${distDir}`);
- console.error(
- "[dispatch-frontend] Build the frontend first (bun run --cwd packages/frontend build)",
- );
- process.exit(1);
-}
-
-const indexPath = join(distDir, "index.html");
-
-function safeResolve(urlPath: string): string | null {
- // Normalize and prevent path-traversal outside distDir.
- const decoded = decodeURIComponent(urlPath);
- const candidate = resolve(distDir, `.${normalize(decoded)}`);
- if (!candidate.startsWith(distDir)) return null;
- return candidate;
-}
-
-Bun.serve({
- port,
- hostname: host,
- async fetch(req) {
- const url = new URL(req.url);
- let pathname = url.pathname;
- if (pathname === "/" || pathname === "") pathname = "/index.html";
-
- const resolved = safeResolve(pathname);
- if (resolved && existsSync(resolved) && statSync(resolved).isFile()) {
- return new Response(Bun.file(resolved));
- }
-
- // SPA fallback — serve index.html for unknown routes
- return new Response(Bun.file(indexPath), {
- headers: { "content-type": "text/html; charset=utf-8" },
- });
- },
-});
-
-console.log(`[dispatch-frontend] serving ${distDir} on http://${host}:${port}`);
diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte
deleted file mode 100644
index f5317ba..0000000
--- a/packages/frontend/src/App.svelte
+++ /dev/null
@@ -1,347 +0,0 @@
-<script lang="ts">
-import { DEFAULT_REASONING_EFFORT } from "@dispatch/core/src/types/index.js";
-import { onMount } from "svelte";
-import AgentBuilder from "./lib/components/AgentBuilder.svelte";
-import ChatInput from "./lib/components/ChatInput.svelte";
-import ChatPanel from "./lib/components/ChatPanel.svelte";
-import Header from "./lib/components/Header.svelte";
-import HotReloadIndicator from "./lib/components/HotReloadIndicator.svelte";
-import PermissionPrompt from "./lib/components/PermissionPrompt.svelte";
-import SidebarPanel from "./lib/components/SidebarPanel.svelte";
-import TabBar from "./lib/components/TabBar.svelte";
-import { config } from "./lib/config.js";
-import { router } from "./lib/router.svelte.js";
-import { tabStore } from "./lib/tabs.svelte.js";
-import { applyTheme, loadStoredTheme } from "./lib/theme.js";
-import type { KeyInfo } from "./lib/types.js";
-import { wsClient } from "./lib/ws.svelte.js";
-
-let modelsData = $state<{ keys: KeyInfo[] }>({
- keys: [],
-});
-
-let sidebarOpen = $state(true);
-
-// Add Key modal state (rendered at page level to escape sidebar transform)
-let showAddKeyModal = $state(false);
-let addKeyProvider = $state("anthropic");
-let addKeyId = $state("");
-let addKeyError = $state<string | null>(null);
-let addKeySaving = $state(false);
-
-async function addNewKey(): Promise<void> {
- if (!addKeyId.trim()) return;
- addKeySaving = true;
- addKeyError = null;
- try {
- const res = await fetch(`${config.apiBase}/models/add-key`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ id: addKeyId.trim(), provider: addKeyProvider }),
- });
- const data = (await res.json()) as { success?: boolean; error?: string };
- if (!res.ok || !data.success) {
- addKeyError = data.error ?? "Failed to add key";
- } else {
- showAddKeyModal = false;
- addKeyId = "";
- addKeyProvider = "anthropic";
- await new Promise((r) => setTimeout(r, 500));
- window.location.reload();
- }
- } catch (e) {
- addKeyError = e instanceof Error ? e.message : "Network error";
- } finally {
- addKeySaving = false;
- }
-}
-
-async function fetchModels() {
- try {
- const res = await fetch(`${config.apiBase}/models`);
- if (!res.ok) return;
- const data = await res.json();
- modelsData = {
- keys: data.keys ?? [],
- };
- } catch {
- // ignore fetch errors
- }
-}
-
-$effect(() => {
- if (tabStore.configReloaded) {
- fetchModels();
- }
-});
-
-// ─── Context-window max lookup ─────────────────────────────────
-// Resolve the active model's MAXIMUM context window from models.dev (via the
-// API), so the Context Window sidebar view can show `current / max`. Cached
-// per provider+model; `null` when unknown (the view then hides the
-// denominator/percentage). Only Claude-backed providers are resolvable.
-let contextLimit = $state<number | null>(null);
-const contextLimitCache = new Map<string, number | null>();
-
-$effect(() => {
- const tab = tabStore.activeTab;
- const keyId = tab?.keyId ?? null;
- const modelId = tab?.modelId ?? null;
- const provider = keyId ? (modelsData.keys.find((k) => k.id === keyId)?.provider ?? null) : null;
-
- if (!provider || !modelId) {
- contextLimit = null;
- return;
- }
-
- const cacheKey = `${provider}/${modelId}`;
- if (contextLimitCache.has(cacheKey)) {
- contextLimit = contextLimitCache.get(cacheKey) ?? null;
- return;
- }
-
- // Clear immediately so a slow/failed fetch can't leave the PREVIOUS
- // model's max on screen (which would render this model's tokens against
- // the wrong denominator). The view degrades to a bare token count until
- // the fetch resolves.
- contextLimit = null;
-
- // Fetch is async; guard against a stale response overwriting a newer
- // selection by re-checking the active tab's key/model on resolve.
- void (async () => {
- try {
- const res = await fetch(
- `${config.apiBase}/models/context-limit?provider=${encodeURIComponent(provider)}&modelId=${encodeURIComponent(modelId)}`,
- );
- if (!res.ok) return;
- const data = (await res.json()) as { contextLimit?: number | null };
- const limit = data.contextLimit ?? null;
- contextLimitCache.set(cacheKey, limit);
- const current = tabStore.activeTab;
- const currentProvider = current?.keyId
- ? (modelsData.keys.find((k) => k.id === current.keyId)?.provider ?? null)
- : null;
- if (currentProvider === provider && current?.modelId === modelId) {
- contextLimit = limit;
- }
- } catch {
- // Leave contextLimit as-is on network error; view falls back to
- // showing the bare token count.
- }
- })();
-});
-
-// ─── Image / PDF capability lookup ─────────────────────────────
-// Resolve whether the active model accepts image/pdf INPUT from models.dev (via
-// the API), so the chat input can block sending an unsupported attachment
-// (no tokens spent) while staying permissive when the capability is unknown.
-// `null` = unknown (catalog offline / unsupported provider) → optimistic allow.
-let imageSupport = $state<{ image: boolean; pdf: boolean } | null>(null);
-const capabilityCache = new Map<string, { image: boolean; pdf: boolean } | null>();
-
-$effect(() => {
- const tab = tabStore.activeTab;
- const keyId = tab?.keyId ?? null;
- const modelId = tab?.modelId ?? null;
- const provider = keyId ? (modelsData.keys.find((k) => k.id === keyId)?.provider ?? null) : null;
-
- if (!provider || !modelId) {
- imageSupport = null;
- return;
- }
-
- const cacheKey = `${provider}/${modelId}`;
- if (capabilityCache.has(cacheKey)) {
- imageSupport = capabilityCache.get(cacheKey) ?? null;
- return;
- }
-
- // Clear immediately so a slow/failed fetch can't leave the PREVIOUS model's
- // capability on screen (which could wrongly block/allow this model).
- imageSupport = null;
-
- void (async () => {
- try {
- const res = await fetch(
- `${config.apiBase}/models/capabilities?provider=${encodeURIComponent(provider)}&modelId=${encodeURIComponent(modelId)}`,
- );
- if (!res.ok) return;
- const data = (await res.json()) as {
- capabilities?: { image: boolean; pdf: boolean } | null;
- };
- const caps = data.capabilities ?? null;
- capabilityCache.set(cacheKey, caps);
- const current = tabStore.activeTab;
- const currentProvider = current?.keyId
- ? (modelsData.keys.find((k) => k.id === current.keyId)?.provider ?? null)
- : null;
- if (currentProvider === provider && current?.modelId === modelId) {
- imageSupport = caps;
- }
- } catch {
- // Leave imageSupport as null (unknown → permissive) on network error.
- }
- })();
-});
-
-onMount(() => {
- // Apply persisted theme (or the shared DEFAULT_THEME if nothing is
- // stored) so the first paint matches what the Settings panel will
- // show as the selected option. Without this, daisyUI falls back to
- // the first theme in `app.css` (light) while Settings shows "dark".
- applyTheme(loadStoredTheme());
-
- // Connect WebSocket in parallel with hydration. The `statuses`
- // snapshot delivered on WS open is idempotent against
- // already-hydrated tabs (the handler reconciles per-tab).
- wsClient.connect();
-
- // Initial models fetch (fire-and-forget; UI tolerates models
- // arriving later than tabs).
- fetchModels();
-
- // Restore tabs from the backend. The user's previous session is
- // the source of truth; only fall back to a fresh tab if nothing
- // was restored (first-ever load, or DB was wiped, or HTTP failed).
- void (async () => {
- const restored = await tabStore.hydrateFromBackend();
- if (restored === 0 && tabStore.tabs.length === 0) {
- await tabStore.createNewTab();
- }
- })();
-
- return () => {
- wsClient.disconnect();
- };
-});
-</script>
-
-<div class="flex flex-col h-screen overflow-hidden">
- <Header onToggleSidebar={() => sidebarOpen = !sidebarOpen} />
-
- {#if router.page === "dashboard"}
- <div class="flex flex-1 overflow-hidden relative">
- <!-- Main chat area -->
- <div class="flex flex-col flex-1 min-w-0 overflow-hidden">
- <TabBar />
- <div class="flex-1 overflow-hidden">
- <ChatPanel />
- </div>
- <ChatInput {contextLimit} {imageSupport} />
- </div>
-
- <!-- Right sidebar: overlay on small screens, inline on large -->
- <div
- class="shrink-0 overflow-x-hidden flex flex-col transition-[width] duration-300 ease-out
- sm:relative sm:z-auto
- absolute right-0 top-0 bottom-0 z-30"
- class:w-80={sidebarOpen}
- class:w-0={!sidebarOpen}
- >
- <div
- class="w-80 flex-1 min-h-0 overflow-y-auto bg-base-100 px-2 py-2 flex flex-col gap-2 [&>*]:shrink-0 transition-transform duration-300 ease-out"
- style="transform: translateX({sidebarOpen ? '0' : '100%'})"
- >
- <SidebarPanel
- keys={modelsData.keys}
- tasks={tabStore.activeTab?.tasks ?? []}
- cacheStats={tabStore.activeTab?.cacheStats ?? null}
- cacheTabTitle={tabStore.activeTab?.title ?? null}
- {contextLimit}
- permissionLog={tabStore.permissionLog}
- apiBase={config.apiBase}
- activeTabId={tabStore.activeTabId}
- activeKeyId={tabStore.activeTab?.keyId ?? null}
- activeModelId={tabStore.activeTab?.modelId ?? null}
- reasoningEffort={tabStore.activeTab?.reasoningEffort ?? DEFAULT_REASONING_EFFORT}
- activeAgentSlug={tabStore.activeTab?.agentSlug ?? null}
- activeTabParentId={tabStore.activeTab?.parentTabId ?? null}
- activeAgentModels={tabStore.activeTab?.agentModels ?? null}
- workingDirectory={tabStore.activeTab?.workingDirectory ?? null}
- onKeyChange={(keyId) => tabStore.setKey(keyId)}
- onModelChange={(keyId, modelId) => tabStore.changeModel(keyId, modelId)}
- onReasoningChange={(effort) => tabStore.setReasoningEffort(effort)}
- onAgentChange={(agent) => tabStore.setAgent(agent)}
- onWorkingDirectoryChange={(dir) => tabStore.setWorkingDirectory(dir)}
- onCompact={() => { const id = tabStore.activeTab?.id; if (id) void tabStore.startCompaction(id); }}
- canCompact={(tabStore.activeTab?.agentStatus ?? "idle") === "idle" && (tabStore.activeTab?.chunks.length ?? 0) > 0 && !(tabStore.activeTab?.compactingSource) && !(tabStore.activeTab?.isCompacting)}
- compacting={tabStore.activeTab?.isCompacting ?? false}
- onAddKey={() => { showAddKeyModal = true; addKeyId = ""; addKeyProvider = "anthropic"; addKeyError = null; }}
- />
- </div>
- </div>
- </div>
- {:else if router.page === "agent-builder"}
- <AgentBuilder keys={modelsData.keys} />
- {/if}
-</div>
-
-<!-- Backdrop for sidebar on small screens -->
-{#if sidebarOpen}
- <!-- svelte-ignore a11y_no_static_element_interactions -->
- <div
- class="fixed inset-0 bg-black/30 z-20 sm:hidden"
- role="button"
- tabindex="0"
- onclick={() => sidebarOpen = false}
- onkeydown={(e) => { if (e.key === 'Escape' || e.key === 'Enter') sidebarOpen = false; }}
- aria-label="Close sidebar"
- ></div>
-{/if}
-
-<!-- Fixed overlay elements -->
-<PermissionPrompt
- pending={tabStore.pendingPermissions}
- onReply={(id, reply) => tabStore.replyPermission(id, reply)}
-/>
-
-<!-- Hot reload indicator fixed top-right -->
-<div class="fixed top-4 right-4 z-50">
- <HotReloadIndicator active={tabStore.configReloaded} />
-</div>
-
-<!-- Add New Key Modal (page-level to escape sidebar transform) -->
-{#if showAddKeyModal}
- <div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" role="dialog">
- <div class="bg-base-100 rounded-lg p-4 w-80 flex flex-col gap-3 shadow-xl">
- <h3 class="text-sm font-semibold">Add New Key</h3>
- <div class="flex flex-col gap-1">
- <label class="text-xs text-base-content/60" for="add-key-provider">Provider</label>
- <select id="add-key-provider" class="select select-bordered select-sm w-full" bind:value={addKeyProvider}>
- <option value="anthropic">Anthropic</option>
- <option value="opencode-go">OpenCode</option>
- <option value="google">Google (Gemini)</option>
- </select>
- </div>
- <div class="flex flex-col gap-1">
- <label class="text-xs text-base-content/60" for="add-key-id">Key ID</label>
- <input
- id="add-key-id"
- type="text"
- class="input input-bordered input-sm w-full"
- placeholder="e.g. claude-max, copilot-2"
- bind:value={addKeyId}
- />
- <p class="text-xs text-base-content/40">Unique identifier for this key</p>
- </div>
- {#if addKeyError}
- <p class="text-xs text-error">{addKeyError}</p>
- {/if}
- <div class="flex justify-end gap-2">
- <button type="button" class="btn btn-sm btn-ghost"
- onclick={() => { showAddKeyModal = false; }}>
- Cancel
- </button>
- <button type="button" class="btn btn-sm btn-primary"
- disabled={!addKeyId.trim() || addKeySaving}
- onclick={addNewKey}>
- {#if addKeySaving}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- Add Key
- {/if}
- </button>
- </div>
- </div>
- </div>
-{/if}
diff --git a/packages/frontend/src/app.css b/packages/frontend/src/app.css
deleted file mode 100644
index 169f7e4..0000000
--- a/packages/frontend/src/app.css
+++ /dev/null
@@ -1,111 +0,0 @@
-@import "tailwindcss";
-@import "highlight.js/styles/atom-one-dark.min.css";
-@plugin "daisyui" {
- themes:
- light, dark, dracula, night, nord, sunset, cyberpunk, forest, cmyk, coffee, caramellatte,
- garden, luxury;
-}
-
-.markdown-body {
- & p {
- margin-block: 0.5em;
- &:first-child {
- margin-block-start: 0;
- }
- &:last-child {
- margin-block-end: 0;
- }
- }
- & h1,
- & h2,
- & h3,
- & h4,
- & h5,
- & h6 {
- font-weight: 600;
- line-height: 1.25;
- margin-block: 0.75em 0.25em;
- &:first-child {
- margin-block-start: 0;
- }
- }
- & h1 {
- font-size: 1.4em;
- }
- & h2 {
- font-size: 1.2em;
- }
- & h3 {
- font-size: 1.1em;
- }
- & ul,
- & ol {
- padding-inline-start: 1.5em;
- margin-block: 0.5em;
- }
- & ul {
- list-style-type: disc;
- }
- & ol {
- list-style-type: decimal;
- }
- & li {
- margin-block: 0.15em;
- }
- & pre {
- overflow-x: auto;
- border-radius: var(--radius-box);
- margin-block: 0.5em;
- background-color: oklch(var(--color-neutral));
- color: oklch(var(--color-neutral-content));
- }
- & pre code {
- display: block;
- padding: 0.75em 1em;
- font-size: 0.8125em;
- line-height: 1.5;
- }
- & :not(pre) > code {
- font-size: 0.875em;
- padding: 0.15em 0.4em;
- border-radius: var(--radius-selector);
- background-color: oklch(var(--color-base-content) / 0.1);
- }
- & blockquote {
- border-inline-start: 3px solid oklch(var(--color-base-content) / 0.2);
- padding-inline-start: 0.75em;
- margin-block: 0.5em;
- opacity: 0.8;
- }
- & a {
- color: oklch(var(--color-primary));
- text-decoration: underline;
- &:hover {
- opacity: 0.8;
- }
- }
- & strong {
- font-weight: 600;
- }
- & table {
- width: 100%;
- border-collapse: collapse;
- margin-block: 0.5em;
- font-size: 0.875em;
- }
- & th,
- & td {
- border: 1px solid oklch(var(--color-base-content) / 0.15);
- padding: 0.4em 0.75em;
- text-align: start;
- }
- & th {
- font-weight: 600;
- background-color: oklch(var(--color-base-200));
- }
- & hr {
- border: none;
- border-top: 1px solid oklch(var(--color-base-content) / 0.2);
- margin-block: 0.75em;
- }
-}
diff --git a/packages/frontend/src/lib/attachment-tokens.ts b/packages/frontend/src/lib/attachment-tokens.ts
deleted file mode 100644
index 79d4cbc..0000000
--- a/packages/frontend/src/lib/attachment-tokens.ts
+++ /dev/null
@@ -1,234 +0,0 @@
-// Inline attachment tokens for the chat input.
-//
-// A pasted image/PDF is represented in the textarea draft as an inline TOKEN
-// (e.g. `【image:a1b2c3】`). The token is ordinary text living inside the draft,
-// so attachments have ORDER relative to typed text and to each other, and the
-// user can reference them positionally ("here is image A: 【image:…】"). The
-// token is also the ONLY handle on an attachment — deleting it (atomic delete,
-// below) detaches the underlying file. There is no separate preview strip.
-//
-// This module is pure (no DOM, no Svelte) so it can be unit-tested directly.
-
-import type { UserContentPart } from "@dispatch/core/src/types/index.js";
-
-export type AttachmentKind = "image" | "pdf";
-
-/** A staged attachment, keyed by its short token id. */
-export interface StagedAttachment {
- id: string;
- kind: AttachmentKind;
- /** IANA media type, e.g. `image/png`, `application/pdf`. */
- mediaType: string;
- /** Base64 payload WITHOUT a `data:` URI prefix. */
- data: string;
- /** Optional original filename (used for PDFs). */
- name?: string;
-}
-
-/**
- * Token grammar: `【<kind>:<id>】` where kind ∈ {image,pdf} and id is 6
- * lowercase alphanumerics. The CJK corner brackets (U+3010/U+3011) are used as
- * delimiters because they're visually distinct and virtually never typed by
- * hand, so a token won't collide with normal prose.
- */
-export const ATTACHMENT_TOKEN_RE = /【(image|pdf):([a-z0-9]{6})】/g;
-
-/** Build the inline token string for a staged attachment id + kind. */
-export function makeAttachmentToken(kind: AttachmentKind, id: string): string {
- return `【${kind}:${id}】`;
-}
-
-/** Generate a short, URL-safe token id (6 lowercase alphanumerics). */
-export function generateTokenId(): string {
- let out = "";
- const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
- // crypto.getRandomValues is available in browsers and modern Node/Bun.
- const cryptoObj = (globalThis as { crypto?: Crypto }).crypto;
- if (cryptoObj?.getRandomValues) {
- const buf = new Uint32Array(6);
- cryptoObj.getRandomValues(buf);
- for (let i = 0; i < 6; i++) out += alphabet[(buf[i] ?? 0) % alphabet.length];
- return out;
- }
- for (let i = 0; i < 6; i++) out += alphabet[Math.floor(Math.random() * alphabet.length)];
- return out;
-}
-
-export interface FoundToken {
- id: string;
- kind: AttachmentKind;
- /** Inclusive start index of the token within the text. */
- start: number;
- /** Exclusive end index of the token within the text. */
- end: number;
-}
-
-/** Find all attachment tokens in `text`, in order of appearance. */
-export function findTokens(text: string): FoundToken[] {
- const out: FoundToken[] = [];
- // Fresh regex per call so `lastIndex` state never leaks between calls.
- const re = new RegExp(ATTACHMENT_TOKEN_RE.source, "g");
- let m: RegExpExecArray | null = re.exec(text);
- while (m !== null) {
- out.push({
- kind: m[1] as AttachmentKind,
- id: m[2] ?? "",
- start: m.index,
- end: m.index + m[0].length,
- });
- m = re.exec(text);
- }
- return out;
-}
-
-/** The set of attachment ids whose token is still intact in `text`. */
-export function intactTokenIds(text: string): Set<string> {
- return new Set(findTokens(text).map((t) => t.id));
-}
-
-export interface DeletionResult {
- /** Text after the deletion. */
- text: string;
- /** New caret position (collapsed) after the deletion. */
- caret: number;
- /** Ids of attachments whose tokens were removed by this deletion. */
- removedIds: string[];
-}
-
-/**
- * Compute the result of a Backspace/Delete keystroke when it interacts with an
- * attachment token, so a token deletes ATOMICALLY (one keystroke removes the
- * whole `【…】`, never a single bracket). Returns `null` when the keystroke does
- * NOT touch a token — the caller should then let the browser's default editing
- * behaviour run.
- *
- * Rules:
- * - Range selection (`selStart !== selEnd`): expand the range to fully cover
- * any token it overlaps, then delete the expanded range. Only acts when at
- * least one token actually overlaps (otherwise returns null).
- * - Collapsed + Backspace: if a token ends exactly at the caret, delete it.
- * - Collapsed + Delete: if a token starts exactly at the caret, delete it.
- */
-export function computeTokenDeletion(
- text: string,
- selStart: number,
- selEnd: number,
- key: "Backspace" | "Delete",
-): DeletionResult | null {
- const tokens = findTokens(text);
- if (tokens.length === 0) return null;
-
- if (selStart !== selEnd) {
- const lo = Math.min(selStart, selEnd);
- const hi = Math.max(selStart, selEnd);
- const overlapping = tokens.filter((t) => t.start < hi && t.end > lo);
- if (overlapping.length === 0) return null;
- const delStart = Math.min(lo, ...overlapping.map((t) => t.start));
- const delEnd = Math.max(hi, ...overlapping.map((t) => t.end));
- return {
- text: text.slice(0, delStart) + text.slice(delEnd),
- caret: delStart,
- removedIds: overlapping.map((t) => t.id),
- };
- }
-
- // Collapsed caret.
- if (key === "Backspace") {
- const tok = tokens.find((t) => t.end === selStart);
- if (!tok) return null;
- return {
- text: text.slice(0, tok.start) + text.slice(tok.end),
- caret: tok.start,
- removedIds: [tok.id],
- };
- }
- // Delete (forward).
- const tok = tokens.find((t) => t.start === selStart);
- if (!tok) return null;
- return {
- text: text.slice(0, tok.start) + text.slice(tok.end),
- caret: tok.start,
- removedIds: [tok.id],
- };
-}
-
-/** Human-readable marker that replaces a token in persisted/display text. */
-export function markerFor(kind: AttachmentKind): string {
- return kind === "pdf" ? "[pdf]" : "[image]";
-}
-
-export interface ParsedDraft {
- /**
- * Text-only projection of the draft with each attachment token replaced by a
- * `[image]` / `[pdf]` marker. This is what gets persisted and rendered in the
- * chat history (the raw bytes are never stored).
- */
- displayText: string;
- /**
- * Ordered multimodal content (interleaved text + attachment parts) to send to
- * the model, or `null` when the draft has no intact attachment token (the
- * caller then sends plain text).
- */
- content: UserContentPart[] | null;
-}
-
-/**
- * Split a draft (text containing attachment tokens) plus the staged-attachment
- * map into:
- * - `displayText`: tokens swapped for `[image]`/`[pdf]` markers, and
- * - `content`: an ordered `UserContentPart[]` interleaving the surrounding text
- * with the matching attachment parts.
- *
- * A token whose id has no matching staged attachment (e.g. a stray paste of the
- * token text, or a detached attachment) is treated as plain text in BOTH
- * outputs — its marker still appears in `displayText`, but it contributes no
- * attachment part. `content` is `null` when no attachment part is produced.
- */
-export function parseDraft(draft: string, attachments: Map<string, StagedAttachment>): ParsedDraft {
- const tokens = findTokens(draft);
- let displayText = "";
- const content: UserContentPart[] = [];
- let textBuf = "";
- let cursor = 0;
- let producedAttachment = false;
-
- const flushText = () => {
- if (textBuf.length > 0) {
- content.push({ type: "text", text: textBuf });
- textBuf = "";
- }
- };
-
- for (const tok of tokens) {
- const between = draft.slice(cursor, tok.start);
- textBuf += between;
- displayText += between;
- const att = attachments.get(tok.id);
- if (att) {
- // displayText (persisted/rendered) gets a `[image]`/`[pdf]` marker;
- // the multimodal content gets the ACTUAL attachment part instead — no
- // marker text, since the part itself represents the file to the model.
- displayText += markerFor(tok.kind);
- flushText();
- content.push({
- type: "attachment",
- mediaType: att.mediaType,
- data: att.data,
- ...(att.name ? { name: att.name } : {}),
- });
- producedAttachment = true;
- } else {
- // Orphan token (no staged attachment) → keep the marker as plain text
- // in BOTH outputs; it contributes no attachment part.
- displayText += markerFor(tok.kind);
- textBuf += markerFor(tok.kind);
- }
- cursor = tok.end;
- }
- const tail = draft.slice(cursor);
- textBuf += tail;
- displayText += tail;
- flushText();
-
- return { displayText, content: producedAttachment ? content : null };
-}
diff --git a/packages/frontend/src/lib/cache-warm-storage.ts b/packages/frontend/src/lib/cache-warm-storage.ts
deleted file mode 100644
index 66a8c31..0000000
--- a/packages/frontend/src/lib/cache-warm-storage.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-/**
- * LocalStorage persistence for the per-tab "prompt cache warming" toggle.
- *
- * Cache warming keeps a tab's provider prompt-cache warm while the tab is idle
- * by periodically replaying its exact cached conversation plus a trivial
- * throwaway turn (see `cache-warming.svelte.ts`). Whether warming is enabled is
- * a per-tab preference that must survive a browser reload.
- *
- * Why localStorage and not the backend `settings` table:
- * - It's a per-device UI preference, not domain state — the same precedent as
- * `dispatch-sidebar-panels`, `dispatch-theme`, and `dispatch-api-url`.
- * - No backend round-trip on every toggle.
- * - Warming itself is a frontend-driven timer; keeping its on/off flag on the
- * frontend keeps the whole feature self-contained.
- *
- * Shape: a single JSON object mapping `tabId -> boolean` under one key, so a
- * closed tab's stale entry is cheap and easy to prune.
- */
-
-const LS_KEY = "dispatch-cache-warming";
-
-type WarmMap = Record<string, boolean>;
-
-/** Read the whole tab→enabled map. Never throws; returns {} on any failure. */
-function readMap(): WarmMap {
- try {
- const raw = localStorage.getItem(LS_KEY);
- if (!raw) return {};
- const parsed: unknown = JSON.parse(raw);
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
- const out: WarmMap = {};
- for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
- if (typeof v === "boolean") out[k] = v;
- }
- return out;
- } catch {
- // localStorage unavailable (private mode / restricted context) or corrupt
- // write from a prior session. Degrade to "nothing persisted".
- return {};
- }
-}
-
-/** Persist the whole map. Best-effort — swallows quota / access errors. */
-function writeMap(map: WarmMap): void {
- try {
- localStorage.setItem(LS_KEY, JSON.stringify(map));
- } catch {
- // Best-effort: the in-memory store remains the source of truth for the
- // current session even if persistence fails.
- }
-}
-
-/** Whether cache warming is enabled for `tabId` (default: false). */
-export function loadCacheWarmEnabled(tabId: string): boolean {
- return readMap()[tabId] === true;
-}
-
-/**
- * Persist the warming toggle for one tab. Writing `false` removes the entry
- * (default is off, so absence is the natural "disabled" representation and
- * keeps stale tabs from accumulating).
- */
-export function saveCacheWarmEnabled(tabId: string, enabled: boolean): void {
- const map = readMap();
- if (enabled) map[tabId] = true;
- else delete map[tabId];
- writeMap(map);
-}
-
-/** Drop a tab's persisted toggle entirely (called when the tab is closed). */
-export function clearCacheWarmEnabled(tabId: string): void {
- const map = readMap();
- if (tabId in map) {
- delete map[tabId];
- writeMap(map);
- }
-}
diff --git a/packages/frontend/src/lib/cache-warming.svelte.ts b/packages/frontend/src/lib/cache-warming.svelte.ts
deleted file mode 100644
index 0253c08..0000000
--- a/packages/frontend/src/lib/cache-warming.svelte.ts
+++ /dev/null
@@ -1,318 +0,0 @@
-/**
- * Prompt-cache WARMING — frontend timer/orchestration store.
- *
- * Keeps a tab's provider prompt-cache warm while the tab is IDLE by firing a
- * cheap "warm" request (`POST /chat/warm`) on a repeating ~4-minute cadence.
- * The backend replays the tab's EXACT cached prefix plus one trivial throwaway
- * turn (see `Agent.warmCache`), which registers a cache READ and refreshes the
- * provider's ~5-min prompt-cache TTL so the user's next real message lands on a
- * warm cache.
- *
- * Lifecycle (driven by the tab store via the `onTurn*` / `onUserMessage` hooks):
- * - A turn ENDS (tab goes idle) → arm: schedule a fire in 4 minutes.
- * - The timer fires → warm, then re-arm 4 minutes out
- * (repeats; resets the countdown each
- * cycle).
- * - A turn is ONGOING (generation active) → never fires; the pending timer is
- * cancelled.
- * - The user sends a real message → disable+reset the timer immediately;
- * the turn it starts re-arms warming
- * once it ends.
- *
- * CRITICAL: the warming request is debug-only. Its cache data is surfaced ONLY
- * as a warming-specific "Last request" percentage here — it is NEVER folded
- * into the real Cache Rate metric, never persisted, never counted toward
- * context. The backend route returns just the request's `usage`; nothing else.
- */
-
-import type { AgentModelEntry } from "@dispatch/core/src/types/index.js";
-import {
- clearCacheWarmEnabled,
- loadCacheWarmEnabled,
- saveCacheWarmEnabled,
-} from "./cache-warm-storage.js";
-import { config } from "./config.js";
-
-/** Re-warm cadence. Comfortably under Claude's ~5-min prompt-cache expiry. */
-export const WARM_INTERVAL_MS = 4 * 60 * 1000;
-
-/** Per-tab request parameters the warm POST needs (resolved from the tab). */
-export interface WarmRequestParams {
- keyId: string | null;
- modelId: string | null;
- agentModels: AgentModelEntry[] | null;
- /**
- * The SAME reasoning effort the next real turn would use. It drives the
- * Anthropic thinking providerOptions, which is a message-cache key — warming
- * must match it so it refreshes the bucket the real message reads.
- */
- reasoningEffort: string | null;
-}
-
-/** Reactive, per-tab warming UI state (read by the Chat Settings debug strip). */
-export interface WarmState {
- /** User toggle (persisted per-tab in localStorage). */
- enabled: boolean;
- /** Epoch ms of the next scheduled fire, or null when not armed. */
- nextFireAt: number | null;
- /**
- * Cache-read % of the most recent warming request (0–100), or null if it
- * has never fired this session. Drives the "-%" → number display.
- */
- lastPct: number | null;
- /** Last warming error (provider/network), surfaced in the debug strip. */
- error: string | null;
- /** True while a warm request is in flight. */
- firing: boolean;
-}
-
-function defaultState(enabled: boolean): WarmState {
- return { enabled, nextFireAt: null, lastPct: null, error: null, firing: false };
-}
-
-function computeCachePct(inputTokens: number, cacheReadTokens: number): number {
- if (inputTokens <= 0) return 0;
- return Math.round(Math.max(0, Math.min(1, cacheReadTokens / inputTokens)) * 100);
-}
-
-export function createCacheWarmingStore() {
- // Reactive per-tab state. Nested mutation is reactive via Svelte 5 proxies;
- // new keys are assigned wholesale (also reactive).
- const states = $state<Record<string, WarmState>>({});
- // Ticking clock so the countdown display refreshes once per second. Only
- // ticked while at least one tab is armed (see (re)startTicker).
- let now = $state(Date.now());
-
- // Non-reactive bookkeeping (timers, in-flight tokens, running set, resolver).
- const fireTimers = new Map<string, ReturnType<typeof setTimeout>>();
- const fireTokens = new Map<string, number>();
- const runningTabs = new Set<string>();
- let ticker: ReturnType<typeof setInterval> | null = null;
- let resolveParams: ((tabId: string) => WarmRequestParams | null) | null = null;
-
- function ensure(tabId: string): WarmState {
- let s = states[tabId];
- if (!s) {
- s = defaultState(loadCacheWarmEnabled(tabId));
- states[tabId] = s;
- }
- return s;
- }
-
- function anyArmed(): boolean {
- for (const s of Object.values(states)) {
- if (s.nextFireAt !== null) return true;
- }
- return false;
- }
-
- function startTickerIfNeeded(): void {
- if (ticker !== null) return;
- if (typeof setInterval !== "function") return;
- ticker = setInterval(() => {
- now = Date.now();
- // Self-stop once nothing is armed, so we don't tick forever.
- if (!anyArmed()) stopTicker();
- }, 1000);
- }
-
- function stopTicker(): void {
- if (ticker !== null) {
- clearInterval(ticker);
- ticker = null;
- }
- }
-
- function clearFireTimer(tabId: string): void {
- const t = fireTimers.get(tabId);
- if (t !== undefined) {
- clearTimeout(t);
- fireTimers.delete(tabId);
- }
- }
-
- /** Cancel any pending fire / in-flight request and clear the countdown. */
- function cancel(tabId: string): void {
- clearFireTimer(tabId);
- // Invalidate any in-flight warm so its late result is ignored.
- fireTokens.set(tabId, (fireTokens.get(tabId) ?? 0) + 1);
- const s = states[tabId];
- if (s) s.nextFireAt = null;
- if (!anyArmed()) stopTicker();
- }
-
- /** Schedule the next fire 4 minutes out — only when enabled AND idle. */
- function arm(tabId: string): void {
- const s = ensure(tabId);
- if (!s.enabled) return;
- if (runningTabs.has(tabId)) return;
- clearFireTimer(tabId);
- s.nextFireAt = Date.now() + WARM_INTERVAL_MS;
- startTickerIfNeeded();
- if (typeof setTimeout === "function") {
- fireTimers.set(
- tabId,
- setTimeout(() => {
- fireTimers.delete(tabId);
- void fire(tabId);
- }, WARM_INTERVAL_MS),
- );
- }
- }
-
- /** Perform one warming request, then (if still eligible) re-arm. */
- async function fire(tabId: string): Promise<void> {
- const s = ensure(tabId);
- if (!s.enabled || runningTabs.has(tabId) || s.firing) {
- return;
- }
- const token = (fireTokens.get(tabId) ?? 0) + 1;
- fireTokens.set(tabId, token);
- const params = resolveParams?.(tabId) ?? null;
-
- s.firing = true;
- s.error = null;
- // Clear the countdown while the request is in flight.
- s.nextFireAt = null;
- try {
- const res = await fetch(`${config.apiBase}/chat/warm`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- tabId,
- ...(params?.keyId ? { keyId: params.keyId } : {}),
- ...(params?.modelId ? { modelId: params.modelId } : {}),
- ...(params?.agentModels ? { agentModels: params.agentModels } : {}),
- ...(params?.reasoningEffort ? { reasoningEffort: params.reasoningEffort } : {}),
- }),
- });
- // A newer cancel/fire superseded this request — drop its result so it
- // can't clobber fresher state (e.g. user sent a real message meanwhile).
- if (fireTokens.get(tabId) !== token) return;
-
- if (!res.ok) {
- let msg = `warm failed (HTTP ${res.status})`;
- try {
- const body = (await res.json()) as { error?: string };
- if (body?.error) msg = body.error;
- } catch {
- /* non-JSON error body — keep the HTTP status message */
- }
- s.error = msg;
- } else {
- const data = (await res.json()) as {
- usage?: { inputTokens?: number; cacheReadTokens?: number };
- };
- const u = data.usage ?? {};
- s.lastPct = computeCachePct(u.inputTokens ?? 0, u.cacheReadTokens ?? 0);
- s.error = null;
- }
- } catch (err) {
- if (fireTokens.get(tabId) !== token) return;
- s.error = err instanceof Error ? err.message : String(err);
- } finally {
- if (fireTokens.get(tabId) === token) {
- s.firing = false;
- // Re-arm for the next cycle (resets the 4-min countdown), but only
- // if still enabled and the tab is still idle.
- if (s.enabled && !runningTabs.has(tabId)) arm(tabId);
- else if (!anyArmed()) stopTicker();
- }
- }
- }
-
- // ─── Public lifecycle hooks (called by the tab store) ────────────
-
- /**
- * Register the resolver the store uses to fetch a tab's request params
- * (key/model/agentModels) at fire time. Called once by the tab store.
- */
- function setRequestResolver(fn: (tabId: string) => WarmRequestParams | null): void {
- resolveParams = fn;
- }
-
- /** Seed a tab's state from persistence. Arms immediately if enabled+idle. */
- function initTab(tabId: string): void {
- const s = ensure(tabId);
- if (s.enabled && !runningTabs.has(tabId) && s.nextFireAt === null) {
- arm(tabId);
- }
- }
-
- /** Toggle warming for a tab (persisted). Arms or cancels accordingly. */
- function setEnabled(tabId: string, enabled: boolean): void {
- const s = ensure(tabId);
- s.enabled = enabled;
- saveCacheWarmEnabled(tabId, enabled);
- if (enabled) arm(tabId);
- else cancel(tabId);
- }
-
- /** A turn started / generation is active — never warm during a turn. */
- function onTurnActive(tabId: string): void {
- runningTabs.add(tabId);
- cancel(tabId);
- }
-
- /** A turn ended (tab idle) — re-arm the 4-minute countdown if enabled. */
- function onTurnEnded(tabId: string): void {
- runningTabs.delete(tabId);
- const s = ensure(tabId);
- if (s.enabled) arm(tabId);
- }
-
- /**
- * The user sent a real message — disable+reset the timer immediately. The
- * turn this message starts will re-arm warming via `onTurnEnded` once it
- * settles, so the real message lands on a cache with no throwaway turns.
- */
- function onUserMessage(tabId: string): void {
- cancel(tabId);
- }
-
- /** Forget a closed tab's timers/state. */
- function removeTab(tabId: string): void {
- cancel(tabId);
- fireTimers.delete(tabId);
- fireTokens.delete(tabId);
- runningTabs.delete(tabId);
- delete states[tabId];
- if (!anyArmed()) stopTicker();
- }
-
- /**
- * Forget a tab AND drop its persisted preference — for an explicit user
- * close/archive. (`removeTab` keeps the persisted flag so an ephemeral
- * idle-cleanup or a later reopen restores the user's choice.)
- */
- function forgetTab(tabId: string): void {
- removeTab(tabId);
- clearCacheWarmEnabled(tabId);
- }
-
- /** Reactive state for a tab (creates a default-off entry if absent). */
- function stateFor(tabId: string | null | undefined): WarmState {
- if (!tabId) return defaultState(false);
- return ensure(tabId);
- }
-
- return {
- setRequestResolver,
- initTab,
- setEnabled,
- onTurnActive,
- onTurnEnded,
- onUserMessage,
- removeTab,
- forgetTab,
- stateFor,
- /** Reactive ticking clock (epoch ms) for countdown rendering. */
- get now() {
- return now;
- },
- // Exposed for tests to drive a fire without waiting 4 minutes.
- fireNow: fire,
- };
-}
-
-export const cacheWarming = createCacheWarmingStore();
diff --git a/packages/frontend/src/lib/components/AgentBuilder.svelte b/packages/frontend/src/lib/components/AgentBuilder.svelte
deleted file mode 100644
index f1c9cdf..0000000
--- a/packages/frontend/src/lib/components/AgentBuilder.svelte
+++ /dev/null
@@ -1,806 +0,0 @@
-<script module>
-// Session-level cache for models per key; avoids refetching across component instances
-const modelCache = new Map();
-</script>
-
-<script lang="ts">
- import {
- DEFAULT_REASONING_EFFORT,
- REASONING_EFFORTS,
- REASONING_EFFORT_LABELS,
- } from "@dispatch/core/src/types/index.js";
- import { config } from "../config.js";
- import { router } from "../router.svelte.js";
- import type { KeyInfo } from "../types.js";
- import SkillsBrowser from "./SkillsBrowser.svelte";
- import ToolPermissions from "./ToolPermissions.svelte";
-
- interface AgentModelEntry {
- key_id: string;
- model_id: string;
- effort?: string;
- }
-
- interface AgentDefinition {
- name: string;
- description: string;
- skills: string[];
- tools: string[];
- models: AgentModelEntry[];
- scope: string;
- slug: string;
- cwd?: string;
- is_subagent?: boolean;
- }
-
- interface DirEntry {
- label: string;
- path: string;
- scope: string;
- }
-
- const { keys = [] }: { keys?: KeyInfo[] } = $props();
-
-
-
- // Portal action (escape stacking context)
- function portal(node: HTMLElement) {
- document.body.appendChild(node);
- return {
- destroy() {
- node.remove();
- },
- };
- }
-
- // State
- let agents = $state<AgentDefinition[]>([]);
- let dirs = $state<DirEntry[]>([]);
- let loading = $state(false);
- let error = $state<string | null>(null);
-
- // Editor state
- let editing = $state(false);
- let editingSlug = $state<string | null>(null); // null = new agent
-
- let formName = $state("");
- let formDescription = $state("");
- let formScope = $state("global");
- let formCwd = $state("");
- let cwdExists = $state<boolean | null>(null);
- let cwdResolved = $state<string | null>(null);
- let cwdCheckTimer: ReturnType<typeof setTimeout> | null = null;
- let formSkills = $state<Set<string>>(new Set());
- let formTools = $state<Set<string>>(new Set());
- let formModels = $state<AgentModelEntry[]>([]);
- let formIsSubagent = $state(false);
-
- // Model selection modal state
- let modelModalIndex = $state<number | null>(null);
- let modelModalType = $state<"key" | "model">("key");
- let modalAvailableModels = $state<string[]>([]);
- let modalLoadingModels = $state(false);
- let modalModelError = $state<string | null>(null);
-
- // Drag-and-drop reorder state
- let dragIndex = $state<number | null>(null);
- let dragOverIndex = $state<number | null>(null);
-
- // Delete confirm
- let deletingAgent = $state<AgentDefinition | null>(null);
-
- function slugify(name: string): string {
- return name
- .toLowerCase()
- .replace(/[^a-z0-9]+/g, "-")
- .replace(/^-+|-+$/g, "")
- || "agent";
- }
-
- async function fetchAgents() {
- loading = true;
- error = null;
- try {
- const res = await fetch(`${config.apiBase}/agents`);
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- const data = await res.json();
- agents = data.agents ?? [];
- dirs = data.dirs ?? [];
- } catch (e) {
- error = e instanceof Error ? e.message : "Failed to fetch agents";
- } finally {
- loading = false;
- }
- }
-
- function handleSkillToggle(key: string, checked: boolean) {
- const next = new Set(formSkills);
- if (checked) next.add(key);
- else next.delete(key);
- formSkills = next;
- }
-
- function startNewAgent() {
- formReady = false;
- editingSlug = null;
- formName = "";
- formDescription = "";
- formScope = dirs[0]?.scope ?? "global";
- formCwd = "";
- formSkills = new Set();
- formTools = new Set();
- formModels = [];
- formIsSubagent = true;
- editing = true;
- // Allow the effect to skip the initial population
- setTimeout(() => { formReady = true; }, 0);
- }
-
- function startEdit(agent: AgentDefinition) {
- formReady = false;
- editingSlug = agent.slug;
- formName = agent.name;
- formDescription = agent.description;
- formScope = agent.scope;
- formCwd = agent.cwd ?? "";
- formSkills = new Set(agent.skills);
- formTools = new Set(agent.tools);
- formModels = agent.models.map((m) => ({ ...m }));
- formIsSubagent = agent.is_subagent ?? false;
- editing = true;
- // Allow the effect to skip the initial population
- setTimeout(() => { formReady = true; }, 0);
- }
-
- function cancelEdit() {
- // Flush any pending debounced save before leaving
- if (debounceTimer) {
- clearTimeout(debounceTimer);
- debounceTimer = null;
- saveAgent();
- }
- formReady = false;
- editing = false;
- editingSlug = null;
- }
-
- function handleToolToggle(id: string, checked: boolean) {
- const next = new Set(formTools);
- if (checked) next.add(id);
- else next.delete(id);
- formTools = next;
- }
-
- function addModelEntry() {
- formModels = [...formModels, { key_id: "", model_id: "" }];
- }
-
- function removeModelEntry(i: number) {
- formModels = formModels.filter((_, idx) => idx !== i);
- }
-
- function setEffortEntry(i: number, effort: string) {
- formModels = formModels.map((m, idx) => {
- if (idx !== i) return m;
- // Empty string = "inherit" (no per-model override). Strip the key so
- // the saved TOML omits `effort` and the call site falls back to the
- // per-tab selector / default.
- if (!effort) {
- const { effort: _dropped, ...rest } = m;
- return rest;
- }
- return { ...m, effort };
- });
- }
-
- async function openKeyModal(i: number) {
- modelModalIndex = i;
- modelModalType = "key";
- }
-
- async function openModelModal(i: number) {
- const entry = formModels[i];
- if (!entry || !entry.key_id) return;
- modelModalIndex = i;
- modelModalType = "model";
- modalModelError = null;
- modalAvailableModels = [];
-
- if (modelCache.has(entry.key_id)) {
- modalAvailableModels = modelCache.get(entry.key_id)!;
- return;
- }
-
- modalLoadingModels = true;
- try {
- const res = await fetch(
- `${config.apiBase}/models/available?keyId=${encodeURIComponent(entry.key_id)}`,
- );
- if (!res.ok) {
- const d = await res.json().catch(() => ({}));
- modalModelError = d.error ?? `HTTP ${res.status}`;
- return;
- }
- const d = await res.json();
- modalAvailableModels = d.models ?? [];
- modelCache.set(entry.key_id, modalAvailableModels);
- } catch (e) {
- modalModelError = e instanceof Error ? e.message : "Failed";
- } finally {
- modalLoadingModels = false;
- }
- }
-
- function selectKey(keyId: string) {
- if (modelModalIndex === null) return;
- const idx = modelModalIndex;
- formModels = formModels.map((m, i) =>
- i === idx ? { key_id: keyId, model_id: "" } : m,
- );
- modelModalIndex = null;
- // Immediately open model selection for the same row
- openModelModal(idx);
- }
-
- function selectModel(model: string) {
- if (modelModalIndex === null) return;
- formModels = formModels.map((m, i) =>
- i === modelModalIndex ? { ...m, model_id: model } : m,
- );
- modelModalIndex = null;
- }
-
- function closeModal() {
- modelModalIndex = null;
- }
-
- let formError = $state<string | null>(null);
- let saving = $state(false);
- let formReady = false;
- let debounceTimer: ReturnType<typeof setTimeout> | null = null;
- let saveAbort: AbortController | null = null;
-
- function isFormValid(): boolean {
- if (!formName.trim()) return false;
- if (formModels.length === 0) return false;
- if (formModels.some((m) => !m.key_id || !m.model_id)) return false;
- return true;
- }
-
- async function saveAgent() {
- if (!isFormValid()) return;
- formError = null;
-
- // Cancel any in-flight save
- saveAbort?.abort();
- const controller = new AbortController();
- saveAbort = controller;
-
- const payload: AgentDefinition = {
- name: formName.trim(),
- description: formDescription.trim(),
- skills: Array.from(formSkills),
- tools: Array.from(formTools),
- models: formModels,
- scope: formScope,
- slug: editingSlug ?? slugify(formName.trim()),
- ...(formCwd.trim() ? { cwd: formCwd.trim() } : {}),
- ...(formIsSubagent ? { is_subagent: true } : {}),
- };
-
- saving = true;
- try {
- const res = await fetch(`${config.apiBase}/agents`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(payload),
- signal: controller.signal,
- });
- if (!res.ok) {
- const d = await res.json().catch(() => ({}));
- formError = d.error ?? `HTTP ${res.status}`;
- return;
- }
- // If this was a new agent, lock in the slug for future saves
- if (!editingSlug) {
- editingSlug = payload.slug;
- }
- await fetchAgents();
- } catch (e) {
- if (e instanceof DOMException && e.name === "AbortError") return;
- formError = e instanceof Error ? e.message : "Failed to save";
- } finally {
- if (saveAbort === controller) {
- saving = false;
- saveAbort = null;
- }
- }
- }
-
- // Check if CWD directory exists (debounced)
- $effect(() => {
- const cwd = formCwd;
- if (!cwd.trim()) {
- cwdExists = null;
- cwdResolved = null;
- return;
- }
- if (cwdCheckTimer) clearTimeout(cwdCheckTimer);
- cwdCheckTimer = setTimeout(async () => {
- try {
- const res = await fetch(
- `${config.apiBase}/agents/check-dir?path=${encodeURIComponent(cwd.trim())}`,
- );
- if (res.ok) {
- const data = await res.json();
- cwdExists = data.exists ?? false;
- cwdResolved = data.resolved ?? null;
- }
- } catch {
- cwdExists = null;
- cwdResolved = null;
- }
- }, 300);
- });
-
- // Auto-save with debounce whenever form fields change
- $effect(() => {
- // Read all reactive form fields to subscribe
- // noinspection: intentionally unused — reading these values subscribes the effect to them
- void [formName, formDescription, formScope, formCwd, formSkills, formTools, formModels, formIsSubagent];
- if (!formReady || !editing) return;
- if (debounceTimer) clearTimeout(debounceTimer);
- debounceTimer = setTimeout(() => {
- saveAgent();
- }, 600);
- });
-
- async function deleteAgent(agent: AgentDefinition) {
- try {
- // Prevent auto-save from re-creating the deleted agent
- formReady = false;
- if (debounceTimer) {
- clearTimeout(debounceTimer);
- debounceTimer = null;
- }
- saveAbort?.abort();
-
- const res = await fetch(
- `${config.apiBase}/agents/${encodeURIComponent(agent.slug)}?scope=${encodeURIComponent(agent.scope)}`,
- { method: "DELETE" },
- );
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- deletingAgent = null;
- editing = false;
- editingSlug = null;
- await fetchAgents();
- } catch (e) {
- error = e instanceof Error ? e.message : "Failed to delete";
- }
- }
-
- const hasName = $derived(formName.trim().length > 0);
-
- const globalAgents = $derived(agents.filter((a) => a.scope === "global"));
- const projectAgents = $derived(agents.filter((a) => a.scope !== "global"));
-
- // Fetch on mount
- $effect(() => {
- fetchAgents();
- });
-</script>
-
-<div class="flex flex-col flex-1 overflow-hidden">
- <!-- Page header -->
- <div class="flex items-center gap-4 px-6 py-4 border-b border-base-300 bg-base-200 shrink-0">
- <h1 class="text-xl font-bold flex-1">Agent Settings</h1>
- <button
- type="button"
- class="btn btn-ghost btn-sm"
- onclick={() => { if (editing) { cancelEdit(); } else { router.navigate("dashboard"); } }}
- >
- {editing ? '← Back to Agent Settings' : '← Back to Dashboard'}
- </button>
- </div>
-
- <div class="flex-1 overflow-y-auto px-6 py-6">
- {#if error}
- <div class="alert alert-error mb-4">{error}</div>
- {/if}
-
- {#if editing}
- <!-- Agent Editor Form -->
- <div class="card bg-base-200 shadow-md">
- <div class="card-body gap-4">
- <h2 class="card-title">{editingSlug ? "Edit Agent" : "New Agent"}</h2>
-
- {#if formError}
- <div class="alert alert-error text-sm">{formError}</div>
- {/if}
-
- <!-- Name -->
- <div class="form-control gap-1">
- <label class="label py-0" for="agent-name">
- <span class="label-text font-semibold">Name *</span>
- </label>
- <input
- id="agent-name"
- type="text"
- class="input input-bordered"
- placeholder="my-agent"
- bind:value={formName}
- />
- {#if formName}
- <span class="text-xs text-base-content/50">slug: {slugify(formName)}</span>
- {/if}
- </div>
-
- <fieldset disabled={!hasName} class="contents">
- <!-- Description -->
- <div class="form-control gap-1">
- <label class="label py-0" for="agent-desc">
- <span class="label-text font-semibold">Description</span>
- </label>
- <input
- id="agent-desc"
- type="text"
- class="input input-bordered"
- placeholder="What does this agent do?"
- bind:value={formDescription}
- />
- </div>
-
- <!-- Is Subagent -->
- <div class="form-control">
- <label class="label cursor-pointer justify-start gap-3 py-1">
- <input
- type="checkbox"
- class="checkbox checkbox-sm rounded-sm"
- bind:checked={formIsSubagent}
- />
- <div>
- <span class="label-text font-semibold">Is Subagent</span>
- <p class="text-xs text-base-content/50">Subagents are hidden from Chat Settings and can only be used by other agents.</p>
- </div>
- </label>
- </div>
-
- <!-- Scope -->
- <div class="form-control gap-1">
- <label class="label py-0" for="agent-scope">
- <span class="label-text font-semibold">Scope</span>
- </label>
- <select id="agent-scope" class="select select-bordered" bind:value={formScope}>
- <option value="global">Global</option>
- {#each dirs.filter((d) => d.scope !== "global") as dir}
- <option value={dir.scope}>{dir.label} ({dir.path})</option>
- {/each}
- </select>
- </div>
-
- <!-- Working Directory -->
- <div class="form-control gap-1">
- <label class="label py-0" for="agent-cwd">
- <span class="label-text font-semibold">Working Directory</span>
- </label>
- <div class="flex items-center gap-2">
- <input
- id="agent-cwd"
- type="text"
- class="input input-bordered font-mono text-sm flex-1"
- placeholder="default (project root)"
- bind:value={formCwd}
- />
- {#if formCwd.trim()}
- {#if cwdExists === true}
- <span class="text-success text-lg" title="Directory exists">&#x2714;</span>
- {:else if cwdExists === false}
- <span class="text-warning text-lg" title="Directory does not exist (will be created)">&#x2716;</span>
- {:else}
- <span class="loading loading-spinner loading-xs"></span>
- {/if}
- {/if}
- </div>
- {#if cwdExists === false && formCwd.trim()}
- <span class="text-xs text-warning">Directory will be created automatically on first message.</span>
- {/if}
- {#if cwdResolved && formCwd.trim() && cwdResolved !== formCwd.trim()}
- <span class="text-xs text-base-content/50 font-mono">{cwdResolved}</span>
- {/if}
- {#if !formCwd.trim()}
- <span class="text-xs text-base-content/50">Absolute or relative path. Relative paths resolve against the parent agent's working directory, or the project root.</span>
- {/if}
- </div>
-
- <!-- Models -->
- <div class="form-control gap-2">
- <div class="label py-0">
- <span class="label-text font-semibold">Models *</span>
- </div>
- <div class="flex flex-col gap-2">
- {#each formModels as entry, i (i)}
- <div
- class="flex items-center gap-2 rounded p-1 transition-colors {dragOverIndex === i ? 'bg-primary/10 border border-primary/30' : ''}"
- draggable="true"
- role="listitem"
- ondragstart={(e) => {
- dragIndex = i;
- if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
- }}
- ondragover={(e) => {
- e.preventDefault();
- if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
- dragOverIndex = i;
- }}
- ondragleave={() => {
- if (dragOverIndex === i) dragOverIndex = null;
- }}
- ondrop={(e) => {
- e.preventDefault();
- if (dragIndex !== null && dragIndex !== i) {
- const reordered = [...formModels];
- const moved = reordered.splice(dragIndex, 1)[0];
- if (moved) {
- reordered.splice(i, 0, moved);
- formModels = reordered;
- }
- }
- dragIndex = null;
- dragOverIndex = null;
- }}
- ondragend={() => { dragIndex = null; dragOverIndex = null; }}
- >
- <span class="badge badge-sm badge-neutral font-mono w-6 shrink-0 cursor-grab">{i + 1}</span>
- <button
- type="button"
- class="btn btn-sm btn-outline flex-1 font-mono truncate"
- onclick={() => openKeyModal(i)}
- >
- {entry.key_id || "Select Key"}
- </button>
- <button
- type="button"
- class="btn btn-sm btn-outline flex-1 font-mono truncate"
- onclick={() => openModelModal(i)}
- disabled={!entry.key_id}
- >
- {entry.model_id || "Select Model"}
- </button>
- <select
- class="select select-bordered select-sm shrink-0 w-36"
- title="Reasoning effort for this model. 'Inherit' uses the per-tab selector / default."
- value={entry.effort ?? ""}
- onchange={(e) => setEffortEntry(i, e.currentTarget.value)}
- >
- <option value="">Inherit ({REASONING_EFFORT_LABELS[DEFAULT_REASONING_EFFORT]})</option>
- {#each REASONING_EFFORTS as effort}
- <option value={effort}>{REASONING_EFFORT_LABELS[effort]}</option>
- {/each}
- </select>
- <button
- type="button"
- class="btn btn-sm btn-ghost text-error"
- onclick={() => removeModelEntry(i)}
- aria-label="Remove model entry"
- >
- ✕
- </button>
- </div>
- {/each}
- </div>
- <button
- type="button"
- class="btn btn-sm btn-ghost w-fit"
- onclick={addModelEntry}
- >
- + Add Model
- </button>
- </div>
-
- <!-- Tools -->
- <div class="form-control gap-2">
- <div class="label py-0">
- <span class="label-text font-semibold">Tools</span>
- </div>
- <ToolPermissions
- checkedTools={formTools}
- onToolToggle={handleToolToggle}
- />
- </div>
-
- <!-- Skills -->
- <div class="form-control gap-2">
- <div class="label py-0">
- <span class="label-text font-semibold">Skills</span>
- </div>
- <SkillsBrowser
- apiBase={config.apiBase}
- checkedSkills={formSkills}
- onSkillToggle={handleSkillToggle}
- />
- </div>
-
- <!-- Actions -->
- <div class="card-actions justify-between items-center pt-2">
- {#if editingSlug && !(editingSlug === "default" && formScope === "global")}
- <button
- type="button"
- class="btn btn-error btn-ghost"
- onclick={() => {
- const agent = agents.find((a) => a.slug === editingSlug && a.scope === formScope);
- if (agent) deletingAgent = agent;
- }}
- >Delete</button>
- {:else}
- <div></div>
- {/if}
- <div class="flex items-center gap-2">
- {#if saving}
- <span class="text-xs text-base-content/50">Saving...</span>
- {/if}
- </div>
- </div>
- </fieldset>
- </div>
- </div>
- {:else}
- <!-- Agent List -->
- {#if loading}
- <div class="flex justify-center py-16">
- <span class="loading loading-spinner loading-lg"></span>
- </div>
- {:else}
- <!-- Global Agents -->
- <section class="mb-8">
- <h2 class="text-lg font-semibold mb-3">Global Agents</h2>
- <div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
- {#each globalAgents as agent}
- <button
- type="button"
- class="card bg-base-200 shadow-sm hover:bg-base-300 transition-colors cursor-pointer text-left"
- onclick={() => startEdit(agent)}
- >
- <div class="card-body p-4 gap-2">
- <h3 class="font-semibold font-mono truncate">{agent.name}</h3>
- {#if agent.description}
- <p class="text-sm text-base-content/60 truncate">{agent.description}</p>
- {/if}
- <div class="flex gap-2 flex-wrap">
- <span class="badge badge-sm badge-neutral">{agent.models.length} model{agent.models.length !== 1 ? "s" : ""}</span>
- <span class="badge badge-sm badge-neutral">{agent.skills.length} skill{agent.skills.length !== 1 ? "s" : ""}</span>
- <span class="badge badge-sm badge-outline">{agent.tools.length} tool{agent.tools.length !== 1 ? "s" : ""}</span>
- </div>
- </div>
- </button>
- {/each}
- <button
- type="button"
- class="card bg-base-200 shadow-sm hover:bg-base-300 transition-colors cursor-pointer border-2 border-dashed border-base-content/20"
- onclick={startNewAgent}
- >
- <div class="card-body p-4 items-center justify-center">
- <span class="text-2xl text-base-content/30">+</span>
- <span class="text-sm text-base-content/50">New Agent</span>
- </div>
- </button>
- </div>
- </section>
-
- <!-- Project Agents -->
- {#if projectAgents.length > 0 || dirs.some((d) => d.scope !== "global")}
- <section>
- <h2 class="text-lg font-semibold mb-3">Project Agents</h2>
- {#if projectAgents.length === 0}
- <p class="text-base-content/50 italic text-sm">No project agents yet.</p>
- {:else}
- <div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
- {#each projectAgents as agent}
- <button
- type="button"
- class="card bg-base-200 shadow-sm hover:bg-base-300 transition-colors cursor-pointer text-left"
- onclick={() => startEdit(agent)}
- >
- <div class="card-body p-4 gap-2">
- <h3 class="font-semibold font-mono truncate">{agent.name}</h3>
- {#if agent.description}
- <p class="text-sm text-base-content/60 truncate">{agent.description}</p>
- {/if}
- <p class="text-xs text-base-content/40 font-mono truncate">{agent.scope}</p>
- <div class="flex gap-2 flex-wrap">
- <span class="badge badge-sm badge-neutral">{agent.models.length} model{agent.models.length !== 1 ? "s" : ""}</span>
- <span class="badge badge-sm badge-neutral">{agent.skills.length} skill{agent.skills.length !== 1 ? "s" : ""}</span>
- <span class="badge badge-sm badge-outline">{agent.tools.length} tool{agent.tools.length !== 1 ? "s" : ""}</span>
- </div>
- </div>
- </button>
- {/each}
- </div>
- {/if}
- </section>
- {/if}
- {/if}
- {/if}
- </div>
-</div>
-
-<!-- Key selection modal -->
-{#if modelModalIndex !== null && modelModalType === "key"}
- <div class="modal modal-open" use:portal>
- <div class="modal-box">
- <h3 class="font-bold text-xl">Select Key</h3>
- <div class="mt-4 flex flex-col gap-2">
- {#each keys as key}
- <button
- type="button"
- class="btn {formModels[modelModalIndex ?? 0]?.key_id === key.id ? 'btn-primary' : 'btn-ghost'} justify-start text-base"
- onclick={() => selectKey(key.id)}
- >
- <span class="font-mono">{key.id}</span>
- <span class="badge ml-auto">{key.provider}</span>
- <span class="badge {key.status === 'active' ? 'badge-success' : 'badge-error'}">{key.status}</span>
- </button>
- {/each}
- </div>
- <div class="modal-action">
- <button type="button" class="btn" onclick={closeModal}>Cancel</button>
- </div>
- </div>
- <button type="button" class="modal-backdrop" onclick={closeModal} aria-label="Close modal"></button>
- </div>
-{/if}
-
-<!-- Model selection modal -->
-{#if modelModalIndex !== null && modelModalType === "model"}
- <div class="modal modal-open" use:portal>
- <div class="modal-box">
- <h3 class="font-bold text-xl">Select Model</h3>
- {#if modalLoadingModels}
- <div class="flex justify-center py-8">
- <span class="loading loading-spinner loading-lg"></span>
- </div>
- {:else if modalModelError}
- <div class="alert alert-error mt-4 text-base">
- <span>{modalModelError}</span>
- </div>
- {:else}
- <div class="mt-4 flex flex-col gap-1 max-h-96 overflow-y-auto">
- {#each modalAvailableModels as model}
- <button
- type="button"
- class="btn {formModels[modelModalIndex ?? 0]?.model_id === model ? 'btn-primary' : 'btn-ghost'} justify-start font-mono text-base"
- onclick={() => selectModel(model)}
- >
- {model}
- </button>
- {/each}
- </div>
- {/if}
- <div class="modal-action">
- <button type="button" class="btn" onclick={closeModal}>Cancel</button>
- </div>
- </div>
- <button type="button" class="modal-backdrop" onclick={closeModal} aria-label="Close modal"></button>
- </div>
-{/if}
-
-<!-- Delete confirm modal -->
-{#if deletingAgent !== null}
- {@const agentToDelete = deletingAgent}
- <div class="modal modal-open" use:portal>
- <div class="modal-box">
- <h3 class="font-bold text-lg">Delete Agent</h3>
- <p class="py-4">
- Are you sure you want to delete <span class="font-mono font-semibold">{agentToDelete.name}</span>? This cannot be undone.
- </p>
- <div class="modal-action">
- <button type="button" class="btn btn-ghost" onclick={() => { deletingAgent = null; }}>Cancel</button>
- <button
- type="button"
- class="btn btn-error"
- onclick={() => deleteAgent(agentToDelete)}
- >Delete</button>
- </div>
- </div>
- <button type="button" class="modal-backdrop" onclick={() => { deletingAgent = null; }} aria-label="Close modal"></button>
- </div>
-{/if}
diff --git a/packages/frontend/src/lib/components/CacheRatePanel.svelte b/packages/frontend/src/lib/components/CacheRatePanel.svelte
deleted file mode 100644
index 88985a0..0000000
--- a/packages/frontend/src/lib/components/CacheRatePanel.svelte
+++ /dev/null
@@ -1,124 +0,0 @@
-<script lang="ts">
-import type { CacheStats } from "../types.js";
-
-const {
- cacheStats = null,
- tabTitle = null,
-}: {
- cacheStats?: CacheStats | null;
- tabTitle?: string | null;
-} = $props();
-
-// Cache hit rate = cached-read tokens / total prompt tokens. `inputTokens` is
-// the TOTAL prompt (fresh + cache read + cache write), so this is the share of
-// the prompt that was served from Anthropic's prompt cache.
-function rate(read: number, totalInput: number): number {
- if (totalInput <= 0) return 0;
- return Math.max(0, Math.min(1, read / totalInput));
-}
-
-// For caching, a HIGH hit rate is GOOD — invert the usual color thresholds.
-function rateClass(r: number): string {
- if (r >= 0.7) return "progress-success";
- if (r >= 0.3) return "progress-warning";
- return "progress-error";
-}
-
-function fmt(n: number): string {
- return n.toLocaleString();
-}
-
-const hitRate = $derived(cacheStats ? rate(cacheStats.cacheReadTokens, cacheStats.inputTokens) : 0);
-const hitPct = $derived(Math.round(hitRate * 100));
-const uncached = $derived(
- cacheStats
- ? Math.max(0, cacheStats.inputTokens - cacheStats.cacheReadTokens - cacheStats.cacheWriteTokens)
- : 0,
-);
-const lastHitPct = $derived(
- cacheStats?.last
- ? Math.round(rate(cacheStats.last.cacheReadTokens, cacheStats.last.inputTokens) * 100)
- : 0,
-);
-</script>
-
-<div class="flex flex-col gap-3 flex-1 min-h-0 overflow-y-auto">
- {#if !cacheStats || cacheStats.requests === 0}
- <p class="text-xs text-base-content/50">
- No cache data yet. Send a message to a Claude model — prompt-cache usage
- appears here after the first response.
- </p>
- {:else}
- <div class="bg-base-200 rounded-lg p-2">
- <div class="flex items-center gap-1.5 mb-2">
- <span class="text-xs font-semibold">Cache Hit Rate</span>
- {#if tabTitle}
- <span class="badge badge-xs badge-ghost">{tabTitle}</span>
- {/if}
- <span class="badge badge-xs ml-auto whitespace-nowrap">{cacheStats.requests} req</span>
- </div>
-
- <!-- Headline cumulative hit rate -->
- <div class="flex flex-col gap-0.5">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Session (this tab)</span>
- <span class="text-xs font-mono">{hitPct}%</span>
- </div>
- <progress
- class="progress w-full h-2 {rateClass(hitRate)}"
- value={hitPct}
- max="100"
- ></progress>
- </div>
-
- <!-- Most recent request -->
- {#if cacheStats.last}
- <div class="flex flex-col gap-0.5 mt-2">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Last request</span>
- <span class="text-xs font-mono">{lastHitPct}%</span>
- </div>
- <progress
- class="progress w-full h-2 {rateClass(lastHitPct / 100)}"
- value={lastHitPct}
- max="100"
- ></progress>
- </div>
- {/if}
- </div>
-
- <!-- Token breakdown (cumulative, this tab) -->
- <div class="bg-base-200 rounded-lg p-2">
- <div class="text-xs font-semibold mb-1.5">Tokens (cumulative)</div>
- <div class="flex flex-col gap-1 pl-1">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">
- <span class="badge badge-xs badge-success badge-soft mr-1">read</span>Cache hits
- </span>
- <span class="text-xs font-mono">{fmt(cacheStats.cacheReadTokens)}</span>
- </div>
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">
- <span class="badge badge-xs badge-warning badge-soft mr-1">write</span>Cache writes
- </span>
- <span class="text-xs font-mono">{fmt(cacheStats.cacheWriteTokens)}</span>
- </div>
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">
- <span class="badge badge-xs badge-error badge-soft mr-1">fresh</span>Uncached input
- </span>
- <span class="text-xs font-mono">{fmt(uncached)}</span>
- </div>
- <div class="border-t border-base-300 my-0.5"></div>
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Total input</span>
- <span class="text-xs font-mono">{fmt(cacheStats.inputTokens)}</span>
- </div>
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Output</span>
- <span class="text-xs font-mono">{fmt(cacheStats.outputTokens)}</span>
- </div>
- </div>
- </div>
- {/if}
-</div>
diff --git a/packages/frontend/src/lib/components/ChatInput.svelte b/packages/frontend/src/lib/components/ChatInput.svelte
deleted file mode 100644
index f3eadf7..0000000
--- a/packages/frontend/src/lib/components/ChatInput.svelte
+++ /dev/null
@@ -1,396 +0,0 @@
-<script lang="ts">
-import {
- ACCEPTED_PDF_MEDIA_TYPE,
- isImageMediaType,
- isPdfMediaType,
- MAX_ATTACHMENTS,
- MAX_IMAGE_BYTES,
- MAX_PDF_BYTES,
-} from "@dispatch/core/src/models/attachments.js";
-import {
- type AttachmentKind,
- computeTokenDeletion,
- generateTokenId,
- makeAttachmentToken,
- parseDraft,
- type StagedAttachment,
-} from "../attachment-tokens.js";
-import { computeContextUsage } from "../context-window.js";
-import { tabStore } from "../tabs.svelte.js";
-
-const {
- contextLimit = null,
- imageSupport = null,
-}: {
- contextLimit?: number | null;
- // Image/PDF INPUT capability for the active model, or `null` when unknown
- // (catalog offline / unsupported provider) — null means "can't verify"
- // (optimistic allow), not a hard no.
- imageSupport?: { image: boolean; pdf: boolean } | null;
-} = $props();
-
-const MAX_LINES = 7;
-
-let inputEl: HTMLTextAreaElement | undefined;
-// Transient error shown when a paste is rejected (bad type / too large / too
-// many). Cleared on the next successful paste or any keystroke.
-let pasteError = $state<string | null>(null);
-
-const agentStatus = $derived(tabStore.activeTab?.agentStatus ?? "idle");
-const tabId = $derived(tabStore.activeTab?.id ?? "");
-// The current input text lives on the active tab (in-memory draft), so
-// switching tabs saves the current draft and restores the target tab's text
-// automatically — drafts are never lost or clobbered by tab switching.
-const inputValue = $derived(tabStore.activeTab?.draft ?? "");
-const attachments = $derived(tabStore.activeTab?.attachments ?? []);
-const cacheStats = $derived(tabStore.activeTab?.cacheStats ?? null);
-
-const isRunning = $derived(agentStatus === "running");
-// Lock input while this tab is mid-compaction: either it's the source
-// conversation being summarized, or it's the transient placeholder tab.
-const compactLocked = $derived(
- (tabStore.activeTab?.isCompacting ?? false) ||
- (tabStore.activeTab?.compactingSource ?? null) !== null ||
- (tabStore.activeTab?.compactionError ?? null) !== null,
-);
-const hasText = $derived(inputValue.trim().length > 0);
-const hasAttachments = $derived(attachments.length > 0);
-// While generating with an empty box, the primary action is "stop". With text
-// in the box, it stays "send" (the message is queued behind the live turn).
-const showStop = $derived(isRunning && !hasText && !hasAttachments);
-
-// ─── Attachment capability gating ──────────────────────────────
-// A definitive "no" from the catalog (imageSupport.image === false with an
-// image staged, or .pdf === false with a pdf staged) blocks the send so no
-// tokens are spent. Unknown capability (imageSupport === null) is permissive.
-const hasImageAttachment = $derived(attachments.some((a) => a.kind === "image"));
-const hasPdfAttachment = $derived(attachments.some((a) => a.kind === "pdf"));
-const imageBlocked = $derived(
- hasImageAttachment && imageSupport !== null && imageSupport.image === false,
-);
-const pdfBlocked = $derived(
- hasPdfAttachment && imageSupport !== null && imageSupport.pdf === false,
-);
-// Attachments require a fresh turn — they can't ride the queue path (which is
-// text-only), so block sending an attachment while the agent is generating.
-const attachmentsWhileRunning = $derived(hasAttachments && isRunning);
-
-const attachmentWarning = $derived.by(() => {
- if (pasteError) return pasteError;
- if (attachmentsWhileRunning)
- return "Wait for the current response to finish before sending images.";
- if (imageBlocked && pdfBlocked)
- return "The selected model doesn't support image or PDF input. Remove the attachments to send.";
- if (imageBlocked)
- return "The selected model doesn't support image input. Remove the image to send.";
- if (pdfBlocked) return "The selected model doesn't support PDF input. Remove the PDF to send.";
- return null;
-});
-
-// Send is blocked (but not the box) when an attachment is definitively
-// unsupported or when attachments are staged mid-generation.
-const sendBlocked = $derived(imageBlocked || pdfBlocked || attachmentsWhileRunning);
-
-const usage = $derived(computeContextUsage(cacheStats, contextLimit));
-const hasUsage = $derived((cacheStats?.last ?? null) !== null);
-
-// As the window fills, escalate color: calm → warning → danger. Mirrors the
-// Context Window sidebar view so the two displays agree.
-function fillClass(pct: number): string {
- if (pct >= 90) return "progress-error";
- if (pct >= 70) return "progress-warning";
- return "progress-success";
-}
-
-// Compact token count for the slim bar (e.g. 12.3k, 1.2M). Full numbers live
-// in the sidebar's Context Window panel.
-function fmtCompact(n: number): string {
- if (n < 1000) return `${n}`;
- if (n < 1_000_000) {
- const k = n / 1000;
- return `${k >= 100 ? Math.round(k) : k.toFixed(1)}k`;
- }
- const m = n / 1_000_000;
- return `${m >= 100 ? Math.round(m) : m.toFixed(1)}M`;
-}
-
-$effect(() => {
- // Re-focus when switching tabs.
- void tabId;
- inputEl?.focus();
-});
-
-function resize() {
- const el = inputEl;
- if (!el) return;
- // Reset height so scrollHeight reflects the content's natural height.
- el.style.height = "auto";
- const style = getComputedStyle(el);
- const lineHeight = Number.parseFloat(style.lineHeight) || 20;
- const paddingY = Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom);
- const borderY =
- Number.parseFloat(style.borderTopWidth) + Number.parseFloat(style.borderBottomWidth);
- const maxHeight = lineHeight * MAX_LINES + paddingY + borderY;
- const next = Math.min(el.scrollHeight, maxHeight);
- el.style.height = `${next}px`;
- el.style.overflowY = el.scrollHeight > maxHeight ? "auto" : "hidden";
-}
-
-// Re-run resize whenever the value changes (covers tab switches and
-// programmatic clears too).
-$effect(() => {
- // Touch inputValue so this effect tracks it.
- void inputValue;
- resize();
-});
-
-function handleInput(e: Event) {
- if (!tabId) return;
- pasteError = null;
- // setDraft also reconciles staged attachments against the surviving tokens,
- // so deleting a token (by any means) detaches its attachment.
- tabStore.setDraft(tabId, (e.currentTarget as HTMLTextAreaElement).value);
-}
-
-function kindForMediaType(mediaType: string): AttachmentKind | null {
- if (isImageMediaType(mediaType)) return "image";
- if (isPdfMediaType(mediaType)) return "pdf";
- return null;
-}
-
-function readAsBase64(file: File): Promise<string> {
- return new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.onload = () => {
- const result = reader.result;
- if (typeof result !== "string") {
- reject(new Error("unexpected reader result"));
- return;
- }
- // Strip the `data:<mediaType>;base64,` prefix → bare base64.
- const comma = result.indexOf(",");
- resolve(comma === -1 ? result : result.slice(comma + 1));
- };
- reader.onerror = () => reject(reader.error ?? new Error("read failed"));
- reader.readAsDataURL(file);
- });
-}
-
-/** Insert `insert` at the textarea's caret, returning the new caret offset. */
-function insertAtCaret(insert: string): number {
- const el = inputEl;
- const text = inputValue;
- const start = el?.selectionStart ?? text.length;
- const end = el?.selectionEnd ?? text.length;
- const next = text.slice(0, start) + insert + text.slice(end);
- if (tabId) tabStore.setDraft(tabId, next);
- return start + insert.length;
-}
-
-async function handlePaste(e: ClipboardEvent) {
- if (!tabId) return;
- const items = e.clipboardData?.items;
- if (!items) return;
- const files: File[] = [];
- for (const item of items) {
- if (item.kind === "file") {
- const file = item.getAsFile();
- if (file) files.push(file);
- }
- }
- // No files in the clipboard → let the default text paste happen.
- if (files.length === 0) return;
- // We're handling at least one file; stop the browser from also pasting a
- // filename / image fallback into the textarea.
- e.preventDefault();
- pasteError = null;
-
- for (const file of files) {
- const kind = kindForMediaType(file.type);
- if (!kind) {
- pasteError = `Unsupported file type: ${file.type || "unknown"}. Allowed: PNG, JPEG, WebP, GIF, PDF.`;
- continue;
- }
- const current = tabStore.activeTab?.attachments ?? [];
- if (current.length >= MAX_ATTACHMENTS) {
- pasteError = `You can attach at most ${MAX_ATTACHMENTS} files per message.`;
- break;
- }
- const limit = kind === "pdf" ? MAX_PDF_BYTES : MAX_IMAGE_BYTES;
- if (file.size > limit) {
- const mb = Math.round(limit / (1024 * 1024));
- pasteError = `${kind === "pdf" ? "PDF" : "Image"} is too large (max ${mb} MB).`;
- continue;
- }
- try {
- const data = await readAsBase64(file);
- const id = generateTokenId();
- const mediaType = kind === "pdf" ? ACCEPTED_PDF_MEDIA_TYPE : file.type;
- const staged: StagedAttachment = {
- id,
- kind,
- mediaType,
- data,
- ...(file.name ? { name: file.name } : {}),
- };
- // Stage first, then insert the token — `setDraft` reconciles against
- // staged attachments, so the attachment must exist before its token
- // appears in the draft.
- tabStore.addAttachment(tabId, staged);
- const caret = insertAtCaret(makeAttachmentToken(kind, id));
- // Restore the caret after the value updates.
- requestAnimationFrame(() => {
- const el = inputEl;
- if (el) {
- el.focus();
- el.setSelectionRange(caret, caret);
- }
- });
- } catch {
- pasteError = "Failed to read the pasted file.";
- }
- }
-}
-
-function handleKeydown(e: KeyboardEvent) {
- if (e.key === "Enter" && !e.shiftKey) {
- e.preventDefault();
- submit();
- return;
- }
- if ((e.key === "Backspace" || e.key === "Delete") && inputEl && tabId) {
- // Atomic token delete: a single Backspace/Delete next to (or a selection
- // overlapping) a `【…】` token removes the whole token in one stroke.
- const result = computeTokenDeletion(
- inputValue,
- inputEl.selectionStart ?? 0,
- inputEl.selectionEnd ?? 0,
- e.key,
- );
- if (result) {
- e.preventDefault();
- tabStore.setDraft(tabId, result.text);
- requestAnimationFrame(() => {
- const el = inputEl;
- if (el) {
- el.focus();
- el.setSelectionRange(result.caret, result.caret);
- }
- });
- }
- }
-}
-
-function submit() {
- if (!tabId) return;
- // Block sending while this tab is mid-compaction (source or placeholder).
- if (compactLocked) return;
- const map = new Map(attachments.map((a) => [a.id, a] as const));
- const { displayText, content } = parseDraft(inputValue, map);
- const trimmed = displayText.trim();
- // Nothing to send (no text and no usable attachment).
- if (!trimmed && !content) return;
- // Don't send when a staged attachment is unsupported / mid-generation.
- if (sendBlocked) return;
- const text = trimmed || displayText;
- tabStore.setDraft(tabId, "");
- void tabStore.sendMessage(text, content ?? undefined);
-}
-
-function primaryAction() {
- if (showStop) {
- tabStore.stopGeneration(tabId);
- return;
- }
- submit();
-}
-</script>
-
-<div class="flex flex-col">
- {#if attachmentWarning}
- <div class="px-3 pt-2 text-xs text-warning flex items-start gap-1">
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-3.5 h-3.5 mt-0.5 shrink-0" aria-hidden="true">
- <path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
- <line x1="12" y1="9" x2="12" y2="13"></line>
- <line x1="12" y1="17" x2="12.01" y2="17"></line>
- </svg>
- <span>{attachmentWarning}</span>
- </div>
- {/if}
- <!-- Top bar: expanding textarea + send/stop action -->
- <div class="flex items-end gap-2 px-3 pt-3 pb-2">
- <textarea
- bind:this={inputEl}
- value={inputValue}
- rows="1"
- placeholder={compactLocked
- ? "Compaction in progress…"
- : "Type a message... (paste an image or PDF to attach)"}
- disabled={compactLocked}
- class="textarea textarea-ghost flex-1 resize-none leading-normal !min-h-0 h-auto"
- onkeydown={handleKeydown}
- oninput={handleInput}
- onpaste={handlePaste}
- ></textarea>
- <!-- Single fixed-width button across all states so the layout never
- shifts when it morphs between Send and Stop. -->
- <button
- type="button"
- class="btn w-20 shrink-0 {showStop ? 'btn-error btn-outline' : 'btn-primary'}"
- disabled={compactLocked || (!showStop && !hasText && !hasAttachments) || sendBlocked}
- onclick={primaryAction}
- title={showStop ? "Stop generation" : sendBlocked ? (attachmentWarning ?? "Cannot send") : "Send message"}
- >
- {#if showStop}
- <span class="loading loading-spinner loading-sm"></span>
- Stop
- {:else}
- Send
- {/if}
- </button>
- </div>
-
- <!-- Bottom bar: status icon · context progress · token count -->
- <div class="flex items-center gap-2 px-3 pb-2 text-xs text-base-content/50">
- <!-- Status icon -->
- <span class="shrink-0">
- {#if agentStatus === "running"}
- <span class="loading loading-spinner loading-xs text-primary"></span>
- {:else if agentStatus === "error"}
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4 text-error" aria-label="Error">
- <circle cx="12" cy="12" r="10"></circle>
- <line x1="12" y1="8" x2="12" y2="12"></line>
- <line x1="12" y1="16" x2="12.01" y2="16"></line>
- </svg>
- {:else}
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4 text-success" aria-label="Idle">
- <polyline points="20 6 9 17 4 12"></polyline>
- </svg>
- {/if}
- </span>
-
- <!-- Context-window fill bar -->
- {#if usage.percent !== null}
- <progress
- class="progress flex-1 h-2 {fillClass(usage.percent)}"
- value={usage.percent}
- max="100"
- ></progress>
- {:else}
- <!-- Model's max context is unknown → inert, disabled bar. -->
- <progress class="progress flex-1 h-2 opacity-40" value="0" max="100"></progress>
- {/if}
-
- <!-- Context size + percent -->
- <span class="shrink-0 font-mono whitespace-nowrap">
- {#if hasUsage}
- {fmtCompact(usage.current)}{#if usage.max !== null}<span class="text-base-content/40"> / {fmtCompact(usage.max)}</span>{/if}
- {#if usage.percent !== null}
- <span class="ml-1">· {usage.percent.toFixed(1)}%</span>
- {/if}
- {:else}
- <span class="text-base-content/40">— tokens</span>
- {/if}
- </span>
- </div>
-</div>
diff --git a/packages/frontend/src/lib/components/ChatMessage.svelte b/packages/frontend/src/lib/components/ChatMessage.svelte
deleted file mode 100644
index 54e99c8..0000000
--- a/packages/frontend/src/lib/components/ChatMessage.svelte
+++ /dev/null
@@ -1,148 +0,0 @@
-<script lang="ts">
-import { appSettings } from "../settings.svelte.js";
-import { tabStore } from "../tabs.svelte.js";
-import type { ChatMessage, Chunk, SystemChunkKind } from "../types.js";
-import MarkdownRenderer from "./MarkdownRenderer.svelte";
-import ToolCallDisplay from "./ToolCallDisplay.svelte";
-
-const { message, tabId }: { message: ChatMessage; tabId?: string } = $props();
-
-const isUser = $derived(message.role === "user");
-const isSystem = $derived(message.role === "system");
-
-// Check if this message is queued: its id starts with "queued-"
-const queuedMessageId = $derived(
- isUser && message.id.startsWith("queued-") ? message.id.slice("queued-".length) : null,
-);
-const isQueued = $derived(queuedMessageId !== null);
-
-function cancelQueued() {
- if (tabId && queuedMessageId) {
- void tabStore.cancelQueuedMessage(tabId, queuedMessageId);
- }
-}
-
-function chunkKey(chunk: Chunk, i: number): string {
- if (chunk.type === "tool-batch") {
- // Stable-ish: first call id + count keeps re-renders sane while streaming.
- return `tb-${chunk.calls[0]?.id ?? i}-${chunk.calls.length}`;
- }
- return `${chunk.type}-${i}`;
-}
-
-const SYSTEM_KIND_LABEL: Record<SystemChunkKind, string> = {
- notice: "Notice",
- "model-changed": "Model changed",
- "config-reload": "Config reload",
- cancelled: "Cancelled",
-};
-
-/**
- * Returns true if the given chunk has visible content worth rendering.
- * Used by `hasRenderableContent` to suppress empty assistant bubbles.
- *
- * Note: `ThinkingChunk.metadata` is intentionally excluded — it is
- * internal wire data (Anthropic's providerMetadata / signature) and
- * must never appear in the UI.
- */
-function chunkHasRenderableContent(chunk: Chunk): boolean {
- switch (chunk.type) {
- case "text":
- return chunk.text.length > 0;
- case "thinking":
- return chunk.text.length > 0;
- case "tool-batch":
- return chunk.calls.length > 0;
- case "error":
- return true;
- case "system":
- return true;
- }
-}
-
-/**
- * True when the assistant bubble has something worth showing.
- * Guards the assistant render path so we don't emit an empty box
- * (e.g. a message that only had empty/signature-only thinking blocks
- * from Anthropic adaptive thinking mode).
- *
- * Streaming messages always have renderable content — the cursor
- * needs somewhere to live.
- */
-const hasRenderableContent = $derived(
- message.isStreaming === true || message.chunks.some(chunkHasRenderableContent),
-);
-</script>
-
-{#snippet renderChunks(chunks: Chunk[], streaming: boolean | undefined)}
- {#each chunks as chunk, i (chunkKey(chunk, i))}
- {#if chunk.type === "text"}
- <MarkdownRenderer text={chunk.text} {streaming} />
- {:else if chunk.type === "thinking"}
- <!-- Skip empty thinking chunks: Anthropic adaptive thinking can emit
- a reasoning-end with a signature but no thinking_delta content.
- The metadata is internal wire data — never displayed. -->
- {#if chunk.text.length > 0}
- <div class="collapse collapse-arrow mb-2 p-1">
- <input type="checkbox" checked={appSettings.autoExpandThinking} />
- <div class="collapse-title text-sm opacity-60 italic py-0 pl-0 pr-8 min-h-0">Thinking...</div>
- <div class="collapse-content text-sm opacity-60 italic p-0">
- <p class="whitespace-pre-wrap mt-1">{chunk.text}</p>
- </div>
- </div>
- {/if}
- {:else if chunk.type === "tool-batch"}
- {#each chunk.calls as call (call.id)}
- <ToolCallDisplay toolCall={call} />
- {/each}
- {:else if chunk.type === "error"}
- <div class="alert alert-error my-2 py-2 px-3 text-sm rounded border border-error/60 bg-error/10 text-error">
- <div class="flex flex-col gap-0.5 w-full">
- <span class="break-words">{chunk.message}</span>
- {#if chunk.statusCode !== undefined}
- <span class="text-xs opacity-70">status {chunk.statusCode}</span>
- {/if}
- </div>
- </div>
- {:else if chunk.type === "system"}
- <div class="my-1 text-xs italic opacity-50 flex gap-1 items-baseline">
- <span class="font-semibold not-italic">{SYSTEM_KIND_LABEL[chunk.kind]}:</span>
- <span class="break-words">{chunk.text}</span>
- </div>
- {/if}
- {/each}
-{/snippet}
-
-{#if isSystem}
- <div class="flex justify-center my-2 px-4">
- <div class="max-w-full text-center">
- {@render renderChunks(message.chunks, false)}
- </div>
- </div>
-{:else if !isUser && !hasRenderableContent}
- <!-- Empty assistant message — no renderable chunks and not streaming.
- Suppressed to avoid an empty bubble (e.g. a turn that produced
- only empty/signature-only thinking blocks from Anthropic adaptive
- thinking mode, or a done event with no content). -->
-{:else}
-<div class="chat chat-start mb-2 [&>.chat-bubble]:max-w-full {isQueued ? 'opacity-60' : ''}">
- <div class="chat-bubble break-words {isUser ? 'chat-bubble-primary w-fit' : 'bg-transparent w-full'}">
- {@render renderChunks(message.chunks, message.isStreaming)}
- {#if message.isStreaming}
- <span class="inline-block w-1.5 h-4 bg-current animate-pulse ml-0.5 align-middle rounded-sm"></span>
- {/if}
- </div>
- {#if isQueued}
- <div class="flex items-center gap-1 mt-0.5 ml-1">
- <span class="badge badge-ghost badge-xs text-base-content/40">queued</span>
- <button
- class="btn btn-xs btn-ghost text-base-content/40 hover:text-error px-1 min-h-0 h-auto"
- onclick={cancelQueued}
- title="Cancel queued message"
- >
- ✕
- </button>
- </div>
- {/if}
-</div>
-{/if}
diff --git a/packages/frontend/src/lib/components/ChatPanel.svelte b/packages/frontend/src/lib/components/ChatPanel.svelte
deleted file mode 100644
index ee1b8ef..0000000
--- a/packages/frontend/src/lib/components/ChatPanel.svelte
+++ /dev/null
@@ -1,181 +0,0 @@
-<script lang="ts">
-import { tick, untrack } from "svelte";
-import { tabStore } from "../tabs.svelte.js";
-import ChatMessageComponent from "./ChatMessage.svelte";
-
-let messagesEl: HTMLDivElement | undefined;
-let userScrolledUp = $state(false);
-let isAutoScrolling = false;
-let isLoadingMore = $state(false);
-
-const renderGroups = $derived(tabStore.activeTab?.renderGroups ?? []);
-const activeTabId = $derived(tabStore.activeTab?.id);
-// Compaction placeholder state for the active tab. `compactingSource` is set on
-// a transient placeholder tab while a conversation is being compacted;
-// `compactionError` is set if it failed.
-const compactingSource = $derived(tabStore.activeTab?.compactingSource ?? null);
-const compactionError = $derived(tabStore.activeTab?.compactionError ?? null);
-
-// Stable, turn-scoped render keys. A bubble's identity is `${turnId}:${role}:${n}`
-// (n = its index among same-(turn,role) messages) rather than the underlying
-// row/client id, so when the live turn reconciles into sealed chunk rows the
-// bubble keeps its identity and does NOT remount (no flash). Falls back to the
-// message id for anything without a turnId (e.g. optimistic/queued messages
-// before turn-start, standalone system notices).
-const keyedMessages = $derived.by(() => {
- const counts = new Map<string, number>();
- return renderGroups.map((m) => {
- if (!m.turnId) return { m, key: m.id };
- const base = `${m.turnId}:${m.role}`;
- const n = counts.get(base) ?? 0;
- counts.set(base, n + 1);
- return { m, key: `${base}:${n}` };
- });
-});
-
-function isNearBottom(el: HTMLElement): boolean {
- return el.scrollHeight - el.scrollTop - el.clientHeight < 64;
-}
-
-function scrollToBottom(animate = false) {
- if (!messagesEl) return;
- messagesEl.scrollTo({
- top: messagesEl.scrollHeight,
- behavior: animate ? "smooth" : "instant",
- });
-}
-
-async function onNearTop() {
- if (isLoadingMore) return;
- const tab = tabStore.activeTab;
- if (!tab) return;
- // Nothing older to load if we're already at the very first message, or if
- // we already hold every message the backend has for this tab.
- if (tab.oldestLoadedSeq !== null && tab.oldestLoadedSeq <= 0) return;
-
- isLoadingMore = true;
- const prevScrollHeight = messagesEl?.scrollHeight ?? 0;
- const prevScrollTop = messagesEl?.scrollTop ?? 0;
- try {
- await tabStore.loadOlderChunks(tab.id);
- // Wait for Svelte to flush the prepended messages into the DOM.
- // Reading `scrollHeight` synchronously after the await would observe
- // the OLD layout (reactive updates are batched), so the scroll
- // correction would be computed against a stale height and the
- // viewport would jump. `tick()` resolves once the DOM reflects the
- // new message list.
- await tick();
- if (messagesEl) {
- const newScrollHeight = messagesEl.scrollHeight;
- const delta = newScrollHeight - prevScrollHeight;
- // Only adjust when content was actually prepended above the
- // viewport. If nothing was added (all duplicates / nothing older),
- // `delta` is 0 and we leave the user where they are instead of
- // snapping to the top.
- if (delta > 0) {
- messagesEl.scrollTop = prevScrollTop + delta;
- }
- }
- } finally {
- isLoadingMore = false;
- }
-}
-
-function handleScroll() {
- if (!messagesEl || isAutoScrolling) return;
- const wasScrolledUp = userScrolledUp;
- userScrolledUp = !isNearBottom(messagesEl);
- if (activeTabId) tabStore.setScrolledUp(activeTabId, userScrolledUp);
- // User just scrolled back to the bottom manually — safe to evict now.
- if (wasScrolledUp && !userScrolledUp && activeTabId) {
- tabStore.evictChunks(activeTabId);
- }
- // Near the top — pull in older history.
- if (userScrolledUp && messagesEl.scrollTop < 200) {
- void onNearTop();
- }
-}
-
-function resumeAutoScroll() {
- userScrolledUp = false;
- isAutoScrolling = true;
- if (activeTabId) {
- tabStore.setScrolledUp(activeTabId, false);
- tabStore.evictChunks(activeTabId);
- }
- scrollToBottom(true);
-}
-
-$effect(() => {
- const count = renderGroups.length;
- void count;
- if (messagesEl) {
- untrack(() => {
- if (!userScrolledUp) scrollToBottom(false);
- });
- }
-});
-
-$effect(() => {
- const prevTabId = activeTabId;
- // Reset scroll state when switching tabs
- userScrolledUp = false;
- isAutoScrolling = false;
- return () => {
- if (prevTabId) {
- tabStore.setScrolledUp(prevTabId, false);
- }
- };
-});
-</script>
-
-<div class="flex flex-col h-full">
- <!-- Messages -->
- <div class="relative flex-1 min-h-0">
- <div
- bind:this={messagesEl}
- class="h-full overflow-y-auto p-4"
- onscroll={handleScroll}
- onscrollend={() => {
- isAutoScrolling = false;
- }}
- >
- {#if isLoadingMore}
- <div class="text-center text-xs text-base-content/40 py-2">Loading earlier messages...</div>
- {/if}
- {#if compactingSource || compactionError}
- <div class="flex flex-col items-center justify-center h-full gap-4 px-6 text-center">
- {#if compactionError}
- <div class="text-2xl font-semibold text-error">Compaction failed</div>
- <div class="text-base text-base-content/70 max-w-md">{compactionError}</div>
- <div class="text-sm text-base-content/50">Close this tab to dismiss — your conversation was not changed.</div>
- {:else}
- <span class="loading loading-spinner loading-lg text-primary"></span>
- <div class="text-2xl font-semibold text-base-content">Please wait, compacting conversation…</div>
- <div class="text-sm text-base-content/50">You can cancel by closing this tab.</div>
- {/if}
- </div>
- {:else if renderGroups.length === 0}
- <div class="flex items-center justify-center h-full text-base-content/40 text-sm">
- Send a message to start a conversation
- </div>
- {/if}
- {#if !compactingSource && !compactionError}
- {#each keyedMessages as { m, key } (key)}
- <ChatMessageComponent message={m} tabId={activeTabId} />
- {/each}
- {/if}
- </div>
-
- <!-- Scroll-to-bottom button -->
- <button
- type="button"
- class="absolute bottom-0 left-1/2 -translate-x-1/2 mb-4 btn btn-sm px-8 rounded-lg shadow-lg transition-opacity duration-200 {userScrolledUp ? 'opacity-100' : 'opacity-0 pointer-events-none'}"
- onclick={resumeAutoScroll}
- aria-label="Scroll to bottom"
- tabindex={userScrolledUp ? 0 : -1}
- >
- &#x25BC;
- </button>
- </div>
-</div>
diff --git a/packages/frontend/src/lib/components/ClaudeReset.svelte b/packages/frontend/src/lib/components/ClaudeReset.svelte
deleted file mode 100644
index eea8744..0000000
--- a/packages/frontend/src/lib/components/ClaudeReset.svelte
+++ /dev/null
@@ -1,375 +0,0 @@
-<script lang="ts">
-import { onDestroy } from "svelte";
-import { SnapshotSequencer } from "../snapshot-sequencer.js";
-
-const { apiBase = "" }: { apiBase?: string } = $props();
-
-/** Fixed offset (hours) from a wake to the "Claude session reset" display.
- * Mirrors the backend constant — kept in sync via the GET response. */
-const DEFAULT_RESET_OFFSET_HOURS = 5;
-const DEFAULT_PROBE_SLOT_MINUTES = [0, 15, 30, 45] as const;
-
-type ProbeMinute = number; // 0 | 15 | 30 | 45 in practice
-type HourSlots = Record<ProbeMinute, number>; // slot minute → next fire ms
-
-interface WakeResult {
- label: string;
- ok: boolean;
- error?: string;
-}
-
-interface LastWake {
- firedAt: number;
- ok: boolean;
- results: WakeResult[];
-}
-
-interface PendingRetry {
- retriesLeft: number;
- nextRetryAt: number;
- reason: string;
-}
-
-interface ScheduleSnapshot {
- /** hour (0-23) → { slotMinute → next fire ms }. */
- schedule: Record<string, Record<string, number>>;
- resetOffsetHours?: number;
- probeSlotMinutes?: number[];
- lastWake?: LastWake | null;
- pendingRetry?: PendingRetry | null;
-}
-
-// Marked hours: hour → { slotMinute → next fire ms }. Empty inner record = not marked.
-let schedule = $state<Record<number, HourSlots>>({});
-let resetOffsetHours = $state<number>(DEFAULT_RESET_OFFSET_HOURS);
-let probeSlotMinutes = $state<readonly number[]>(DEFAULT_PROBE_SLOT_MINUTES);
-let lastWake = $state<LastWake | null>(null);
-let pendingRetry = $state<PendingRetry | null>(null);
-
-/**
- * Global mutation lock: the hour whose toggle POST is currently in flight,
- * or null if none. Disables ALL toggle buttons (not just this hour's) while
- * any mutation is pending.
- *
- * Why global, not per-hour: snapshot responses can be reordered on the wire,
- * but worse, requests themselves can be reordered. If two POSTs are in
- * flight and the SERVER processes them out of order, the snapshot the
- * SnapshotSequencer picks as "winner" (highest client-send seq) may not be
- * the snapshot reflecting the truest server state — the UI desyncs from
- * the server permanently. Serializing mutations on the client eliminates
- * the reorder window entirely. (The sequencer is still useful for the
- * GET-on-mount vs first-click race.)
- */
-let pendingHour = $state<number | null>(null);
-
-/**
- * Single global sequencer for ALL /models/wake-schedule responses (initial
- * GET + every toggle POST). Each response is dropped if a newer request has
- * already won. This protects against three races:
- * 1. Two toggles on different hours land out of order — older snapshot
- * blindly overwrites the newer one, and the most-recent click vanishes
- * from the UI.
- * 2. The initial loadFromServer is still in flight when the user clicks.
- * 3. Any future fan-out (e.g. polling) racing a user action.
- * A per-hour counter was insufficient because applySnapshot replaces the
- * whole `schedule` object, not just one hour's slot. See snapshot-sequencer.ts.
- */
-const sequencer = new SnapshotSequencer();
-
-/** Live "now" used for the current-hour ring + relative timestamps. */
-let nowMs = $state<number>(Date.now());
-
-const nowTimer = setInterval(() => {
- nowMs = Date.now();
-}, 30_000);
-
-onDestroy(() => {
- clearInterval(nowTimer);
-});
-
-function formatHour(h: number): string {
- const display = h % 12;
- return display === 0 ? "12" : String(display);
-}
-
-/**
- * Compute the next occurrence of HH:MM in the user's local timezone.
- * Today if still future, else tomorrow.
- */
-function nextOccurrenceAt(hour: number, minute: number): number {
- const target = new Date();
- target.setHours(hour, minute, 0, 0);
- if (target.getTime() <= Date.now()) {
- target.setDate(target.getDate() + 1);
- }
- return target.getTime();
-}
-
-function applySnapshot(data: ScheduleSnapshot): void {
- const parsed: Record<number, HourSlots> = {};
- for (const [hourStr, slots] of Object.entries(data.schedule ?? {})) {
- const inner: HourSlots = {};
- for (const [slotStr, ts] of Object.entries(slots ?? {})) {
- inner[Number(slotStr)] = ts;
- }
- parsed[Number(hourStr)] = inner;
- }
- schedule = parsed;
- if (typeof data.resetOffsetHours === "number") {
- resetOffsetHours = data.resetOffsetHours;
- }
- if (Array.isArray(data.probeSlotMinutes) && data.probeSlotMinutes.length > 0) {
- probeSlotMinutes = data.probeSlotMinutes;
- }
- lastWake = data.lastWake ?? null;
- pendingRetry = data.pendingRetry ?? null;
-}
-
-async function loadFromServer(): Promise<void> {
- const mySeq = sequencer.begin();
- try {
- const res = await fetch(`${apiBase}/models/wake-schedule`);
- if (!res.ok) return;
- const data = (await res.json()) as ScheduleSnapshot;
- if (!sequencer.accept(mySeq)) return; // a newer response already won
- applySnapshot(data);
- } catch {
- // Network error — leave existing state
- }
-}
-
-function setPending(hour: number | null): void {
- pendingHour = hour;
-}
-
-async function postToggle(
- hour: number,
- action: "on" | "off",
- timestamps?: Record<number, number>,
-): Promise<void> {
- const mySeq = sequencer.begin();
- setPending(hour);
-
- try {
- const body: {
- hour: number;
- action: "on" | "off";
- timestamps?: Record<string, number>;
- } = { hour, action };
- if (timestamps) {
- const stringKeyed: Record<string, number> = {};
- for (const [k, v] of Object.entries(timestamps)) stringKeyed[k] = v;
- body.timestamps = stringKeyed;
- }
- const res = await fetch(`${apiBase}/models/wake-schedule/toggle`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- });
- if (!res.ok) return;
- const data = (await res.json()) as ScheduleSnapshot;
- // Drop stale snapshots — only the most-recent request wins for ALL
- // shared state (schedule, lastWake, pendingRetry). Even with the
- // global mutation lock, this still catches the GET-on-mount vs
- // first-click race.
- if (!sequencer.accept(mySeq)) return;
- applySnapshot(data);
- } catch {
- // Network error — leave local state alone; user can re-toggle.
- } finally {
- setPending(null);
- }
-}
-
-function toggleHour(hour: number): void {
- // Global lock: any pending mutation on ANY hour blocks new clicks. This
- // serializes POSTs on the wire so the server never has to choose between
- // two concurrent requests — eliminating the request-reorder failure mode
- // where the sequencer's "highest client seq wins" rule would discard the
- // snapshot reflecting the true server state.
- if (pendingHour !== null) return;
- if (schedule[hour] !== undefined) {
- // User intent: turn this hour OFF.
- void postToggle(hour, "off");
- } else {
- // User intent: turn this hour ON — compute first occurrence of HH:MM
- // for each probe slot, in the user's local timezone.
- const timestamps: Record<number, number> = {};
- for (const minute of probeSlotMinutes) {
- timestamps[minute] = nextOccurrenceAt(hour, minute);
- }
- void postToggle(hour, "on", timestamps);
- }
-}
-
-$effect(() => {
- void loadFromServer();
-});
-
-/**
- * Faded hours: the `resetOffsetHours - 1` hours immediately after each
- * marked hour (the "active session window"). Excludes hours that are
- * themselves marked.
- */
-const fadedHours = $derived.by((): Set<number> => {
- const result = new Set<number>();
- const window = Math.max(0, resetOffsetHours - 1);
- for (const h of Object.keys(schedule).map(Number)) {
- for (let i = 1; i <= window; i++) {
- const faded = (h + i) % 24;
- if (schedule[faded] === undefined) {
- result.add(faded);
- }
- }
- }
- return result;
-});
-
-const currentHour = $derived(new Date(nowMs).getHours());
-
-function blockClass(hour: number, faded: Set<number>): string {
- const isMarked = schedule[hour] !== undefined;
- const isCurrent = hour === currentHour;
- const isFaded = faded.has(hour);
- // Only the hour whose request is in flight shows the "wait" cursor;
- // other buttons are merely disabled (via the template `disabled={...}`).
- const isPending = pendingHour === hour;
-
- let base =
- "flex items-center justify-center rounded select-none text-[10px] font-mono transition-colors";
-
- if (isPending) {
- base += " opacity-60 cursor-wait";
- } else {
- base += " cursor-pointer";
- }
-
- if (isMarked) {
- base += " bg-primary text-primary-content";
- } else if (isFaded) {
- base += " bg-primary/25 text-base-content";
- } else {
- base += " bg-base-300 text-base-content/60 hover:bg-base-content/10";
- }
-
- if (isCurrent) {
- base += " ring-2 ring-accent ring-offset-1 ring-offset-base-200";
- }
-
- return base;
-}
-
-function formatAmPm(hour24: number): string {
- const h = hour24 % 12;
- const ampm = hour24 < 12 ? "AM" : "PM";
- return `${h === 0 ? "12" : String(h)}:00 ${ampm}`;
-}
-
-function resetHour(wakeHour: number): number {
- return (wakeHour + resetOffsetHours) % 24;
-}
-
-function formatRelative(ts: number, now: number): string {
- const diff = now - ts;
- if (diff < 0) {
- const ahead = -diff;
- if (ahead < 60_000) return "in <1 min";
- if (ahead < 3600_000) return `in ${Math.round(ahead / 60_000)} min`;
- return `in ${Math.round(ahead / 3600_000)} h`;
- }
- if (diff < 60_000) return "just now";
- if (diff < 3600_000) return `${Math.round(diff / 60_000)} min ago`;
- if (diff < 86_400_000) return `${Math.round(diff / 3600_000)} h ago`;
- return `${Math.round(diff / 86_400_000)} d ago`;
-}
-
-const markedHours = $derived(
- Object.keys(schedule)
- .map(Number)
- .sort((a, b) => a - b),
-);
-
-function probeLabel(minute: number): string {
- return `:${String(minute).padStart(2, "0")}`;
-}
-
-const probeLabels = $derived(probeSlotMinutes.map(probeLabel).join(" "));
-
-const amRow1 = Array.from({ length: 6 }, (_, i) => i); // 0–5
-const amRow2 = Array.from({ length: 6 }, (_, i) => i + 6); // 6–11
-const pmRow1 = Array.from({ length: 6 }, (_, i) => i + 12); // 12–17
-const pmRow2 = Array.from({ length: 6 }, (_, i) => i + 18); // 18–23
-</script>
-
-<div class="flex flex-col gap-2">
- <div class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Claude Wake Schedule</div>
-
- <!-- AM rows -->
- <div class="flex items-center gap-1">
- <span class="text-[10px] font-semibold text-base-content/40 w-5 shrink-0">AM</span>
- <div class="flex flex-col gap-0.5">
- <div class="flex gap-0.5">
- {#each amRow1 as hour}
- <button type="button" class="{blockClass(hour, fadedHours)} w-[22px] h-[24px]" disabled={pendingHour !== null} onclick={() => toggleHour(hour)} title="{formatHour(hour)} AM — probes at {probeLabels}">
- {formatHour(hour)}
- </button>
- {/each}
- </div>
- <div class="flex gap-0.5">
- {#each amRow2 as hour}
- <button type="button" class="{blockClass(hour, fadedHours)} w-[22px] h-[24px]" disabled={pendingHour !== null} onclick={() => toggleHour(hour)} title="{formatHour(hour)} AM — probes at {probeLabels}">
- {formatHour(hour)}
- </button>
- {/each}
- </div>
- </div>
- </div>
-
- <!-- PM rows -->
- <div class="flex items-center gap-1">
- <span class="text-[10px] font-semibold text-base-content/40 w-5 shrink-0">PM</span>
- <div class="flex flex-col gap-0.5">
- <div class="flex gap-0.5">
- {#each pmRow1 as hour}
- <button type="button" class="{blockClass(hour, fadedHours)} w-[22px] h-[24px]" disabled={pendingHour !== null} onclick={() => toggleHour(hour)} title="{formatHour(hour)} PM — probes at {probeLabels}">
- {formatHour(hour)}
- </button>
- {/each}
- </div>
- <div class="flex gap-0.5">
- {#each pmRow2 as hour}
- <button type="button" class="{blockClass(hour, fadedHours)} w-[22px] h-[24px]" disabled={pendingHour !== null} onclick={() => toggleHour(hour)} title="{formatHour(hour)} PM — probes at {probeLabels}">
- {formatHour(hour)}
- </button>
- {/each}
- </div>
- </div>
- </div>
-
- <!-- Marked hours summary -->
- {#if markedHours.length > 0}
- <div class="flex flex-col gap-0.5 mt-1">
- {#each markedHours as hour}
- <div class="flex items-center gap-1.5 text-xs text-base-content/70">
- <span class="badge badge-xs badge-primary whitespace-nowrap shrink-0">{formatHour(hour)} {hour < 12 ? "AM" : "PM"}</span>
- <span>Probes {probeLabels} → reset by {formatAmPm(resetHour(hour))}</span>
- </div>
- {/each}
- </div>
- {:else}
- <p class="text-xs text-base-content/40 italic">No wake hours marked. Click a block to probe at {probeLabels} that hour.</p>
- {/if}
-
- <!-- Status: last wake / pending retry -->
- {#if lastWake}
- <div class="flex items-center gap-1.5 text-xs mt-1" class:text-success={lastWake.ok} class:text-error={!lastWake.ok}>
- <span class="font-semibold">{lastWake.ok ? "✓" : "✗"}</span>
- <span>Last wake {formatRelative(lastWake.firedAt, nowMs)}{lastWake.ok ? "" : ` — ${lastWake.results.find((r) => !r.ok)?.error ?? "failed"}`}</span>
- </div>
- {/if}
- {#if pendingRetry}
- <div class="text-xs text-warning">
- Retrying ({pendingRetry.retriesLeft} left, next {formatRelative(pendingRetry.nextRetryAt, nowMs)})
- </div>
- {/if}
-</div>
diff --git a/packages/frontend/src/lib/components/ConfigPanel.svelte b/packages/frontend/src/lib/components/ConfigPanel.svelte
deleted file mode 100644
index f517d28..0000000
--- a/packages/frontend/src/lib/components/ConfigPanel.svelte
+++ /dev/null
@@ -1,251 +0,0 @@
-<script lang="ts">
-interface AgentTemplate {
- name: string;
- description?: string;
- model_tag?: string;
- tools?: string[];
-}
-
-interface ModelEntry {
- id: string;
- provider?: string;
- tags?: string[];
-}
-
-interface KeyEntry {
- id: string;
- provider?: string;
- status?: string;
- lastError?: string;
- exhaustedAt?: string;
-}
-
-interface ConfigData {
- agents?: Record<string, AgentTemplate>;
- models?: Record<string, { provider?: string; tags?: string[] }>;
- keys?: Record<string, { env?: string }>;
- fallback?: string[];
- permissions?: Record<string, unknown>;
-}
-
-interface ModelsData {
- models?: ModelEntry[];
- tags?: Record<string, string[]>;
- keys?: KeyEntry[];
-}
-
-const { apiBase }: { apiBase: string } = $props();
-
-let configData = $state<ConfigData | null>(null);
-let modelsData = $state<ModelsData | null>(null);
-let error = $state<string | null>(null);
-let loading = $state(false);
-
-async function fetchData() {
- loading = true;
- error = null;
- try {
- const [configRes, modelsRes] = await Promise.all([
- fetch(`${apiBase}/config`),
- fetch(`${apiBase}/models`),
- ]);
- if (!configRes.ok) throw new Error(`/config returned ${configRes.status}`);
- if (!modelsRes.ok) throw new Error(`/models returned ${modelsRes.status}`);
- const configJson = await configRes.json();
- const modelsJson = await modelsRes.json();
- configData = configJson.config ?? configJson;
- modelsData = modelsJson;
- } catch (e) {
- error = e instanceof Error ? e.message : String(e);
- } finally {
- loading = false;
- }
-}
-
-$effect(() => {
- fetchData();
-});
-
-const modelCount = $derived(modelsData?.models?.length ?? 0);
-const keyCount = $derived(modelsData?.keys?.length ?? 0);
-
-function formatDate(iso: string | undefined): string {
- if (!iso) return "";
- try {
- return new Date(iso).toLocaleString();
- } catch {
- return iso;
- }
-}
-
-function permissionEntries(
- permissions: Record<string, unknown>,
-): Array<{ name: string; value: unknown }> {
- return Object.entries(permissions).map(([name, value]) => ({ name, value }));
-}
-
-function isSimpleRule(value: unknown): value is { action: string } {
- return (
- typeof value === "object" &&
- value !== null &&
- "action" in value &&
- Object.keys(value).length === 1
- );
-}
-
-function isPatternRule(value: unknown): value is Record<string, { action: string }> {
- return typeof value === "object" && value !== null && !("action" in value);
-}
-</script>
-
-<details class="collapse collapse-arrow bg-base-200 mt-2">
- <summary class="collapse-title text-sm font-medium flex items-center gap-2">
- <span>Configuration</span>
- {#if modelCount > 0 || keyCount > 0}
- <span class="badge badge-sm badge-neutral">{modelCount} models</span>
- <span class="badge badge-sm badge-neutral">{keyCount} keys</span>
- {/if}
- {#if loading}
- <span class="loading loading-spinner loading-xs ml-auto"></span>
- {/if}
- </summary>
-
- <div class="collapse-content text-xs">
- {#if error}
- <div class="alert alert-error alert-sm py-1 mb-2 text-xs">
- <span>Failed to load config: {error}</span>
- </div>
- {/if}
-
- <div class="flex justify-end mb-2">
- <button
- type="button"
- class="btn btn-xs btn-ghost"
- onclick={fetchData}
- disabled={loading}
- >
- {loading ? "Loading…" : "Refresh"}
- </button>
- </div>
-
- <!-- Agent Templates -->
- {#if configData?.agents && Object.keys(configData.agents).length > 0}
- <div class="mb-3">
- <div class="text-xs font-semibold text-base-content/60 uppercase tracking-wide mb-1">Agent Templates</div>
- {#each Object.entries(configData.agents) as [name, template]}
- <div class="bg-base-100 rounded p-2 mb-1">
- <div class="flex items-center gap-2 flex-wrap">
- <span class="font-medium">{name}</span>
- {#if template.model_tag}
- <span class="badge badge-xs badge-info">{template.model_tag}</span>
- {/if}
- </div>
- {#if template.description}
- <p class="text-base-content/60 mt-0.5">{template.description}</p>
- {/if}
- {#if template.tools && template.tools.length > 0}
- <div class="flex flex-wrap gap-1 mt-1">
- {#each template.tools as tool}
- <span class="badge badge-xs badge-ghost">{tool}</span>
- {/each}
- </div>
- {/if}
- </div>
- {/each}
- </div>
- {/if}
-
- <!-- Models -->
- {#if modelsData?.models && modelsData.models.length > 0}
- <div class="mb-3">
- <div class="text-xs font-semibold text-base-content/60 uppercase tracking-wide mb-1">Models</div>
- {#each modelsData.models as model}
- <div class="flex items-center gap-2 flex-wrap py-0.5 border-b border-base-300">
- <span class="font-mono">{model.id}</span>
- {#if model.provider}
- <span class="badge badge-xs badge-primary">{model.provider}</span>
- {/if}
- {#if model.tags && model.tags.length > 0}
- {#each model.tags as tag}
- <span class="badge badge-xs badge-ghost">{tag}</span>
- {/each}
- {/if}
- </div>
- {/each}
- </div>
- {/if}
-
- <!-- Keys -->
- {#if modelsData?.keys && modelsData.keys.length > 0}
- <div class="mb-3">
- <div class="text-xs font-semibold text-base-content/60 uppercase tracking-wide mb-1">API Keys</div>
- {#each modelsData.keys as key}
- <div class="bg-base-100 rounded p-1.5 mb-1">
- <div class="flex items-center gap-2 flex-wrap">
- <span class="font-mono">{key.id}</span>
- {#if key.provider}
- <span class="badge badge-xs badge-secondary">{key.provider}</span>
- {/if}
- {#if key.status === "exhausted"}
- <span class="badge badge-xs badge-error">exhausted</span>
- {:else}
- <span class="badge badge-xs badge-success">active</span>
- {/if}
- </div>
- {#if key.status === "exhausted" && key.lastError}
- <p class="text-error/80 mt-0.5 truncate">{key.lastError}</p>
- {/if}
- {#if key.exhaustedAt}
- <p class="text-base-content/40 mt-0.5">Since {formatDate(key.exhaustedAt)}</p>
- {/if}
- </div>
- {/each}
- </div>
- {/if}
-
- <!-- Fallback Order -->
- {#if configData?.fallback && configData.fallback.length > 0}
- <div class="mb-3">
- <div class="text-xs font-semibold text-base-content/60 uppercase tracking-wide mb-1">Fallback Order</div>
- <ol class="list-none space-y-0.5">
- {#each configData.fallback as keyId, i}
- <li class="flex items-center gap-2">
- <span class="badge badge-xs badge-outline">{i + 1}</span>
- <span class="font-mono">{keyId}</span>
- </li>
- {/each}
- </ol>
- </div>
- {/if}
-
- <!-- Permissions -->
- {#if configData?.permissions && Object.keys(configData.permissions).length > 0}
- <div class="mb-1">
- <div class="text-xs font-semibold text-base-content/60 uppercase tracking-wide mb-1">Permissions</div>
- {#each permissionEntries(configData.permissions) as entry}
- <div class="py-0.5 border-b border-base-300">
- <span class="font-medium text-base-content/80">{entry.name}</span>
- {#if isSimpleRule(entry.value)}
- <span class="ml-2 badge badge-xs {entry.value.action === 'allow' ? 'badge-success' : 'badge-error'}">{entry.value.action}</span>
- {:else if isPatternRule(entry.value)}
- {#each Object.entries(entry.value) as [pattern, rule]}
- <div class="pl-2 flex items-center gap-2">
- <span class="text-base-content/50 font-mono truncate max-w-32">{pattern}</span>
- {#if typeof rule === "object" && rule !== null && "action" in rule}
- <span class="badge badge-xs {(rule as { action: string }).action === 'allow' ? 'badge-success' : 'badge-error'}">{(rule as { action: string }).action}</span>
- {/if}
- </div>
- {/each}
- {:else}
- <span class="text-base-content/40 ml-2">{JSON.stringify(entry.value)}</span>
- {/if}
- </div>
- {/each}
- </div>
- {/if}
-
- {#if !loading && !error && !configData && !modelsData}
- <p class="text-base-content/40 italic">No configuration loaded.</p>
- {/if}
- </div>
-</details>
diff --git a/packages/frontend/src/lib/components/ContextWindowPanel.svelte b/packages/frontend/src/lib/components/ContextWindowPanel.svelte
deleted file mode 100644
index 6c7de05..0000000
--- a/packages/frontend/src/lib/components/ContextWindowPanel.svelte
+++ /dev/null
@@ -1,85 +0,0 @@
-<script lang="ts">
-import { computeContextUsage } from "../context-window.js";
-import type { CacheStats } from "../types.js";
-
-const {
- cacheStats = null,
- contextLimit = null,
- tabTitle = null,
- modelId = null,
-}: {
- cacheStats?: CacheStats | null;
- contextLimit?: number | null;
- tabTitle?: string | null;
- modelId?: string | null;
-} = $props();
-
-const usage = $derived(computeContextUsage(cacheStats, contextLimit));
-
-// As the window fills, escalate color: calm → warning → danger.
-function fillClass(pct: number): string {
- if (pct >= 90) return "progress-error";
- if (pct >= 70) return "progress-warning";
- return "progress-success";
-}
-
-function fmt(n: number): string {
- return n.toLocaleString();
-}
-
-const hasUsage = $derived((cacheStats?.last ?? null) !== null);
-</script>
-
-<div class="flex flex-col gap-3 flex-1 min-h-0 overflow-y-auto">
- {#if !hasUsage}
- <p class="text-xs text-base-content/50">
- No context data yet. Send a message — the current context size appears
- here after the first response.
- </p>
- {:else}
- <div class="bg-base-200 rounded-lg p-2">
- <div class="flex items-center gap-1.5 mb-2">
- <span class="text-xs font-semibold">Context Window</span>
- {#if tabTitle}
- <span class="badge badge-xs badge-ghost">{tabTitle}</span>
- {/if}
- {#if usage.percent !== null}
- <span class="badge badge-xs ml-auto">{usage.percent.toFixed(2)}%</span>
- {/if}
- </div>
-
- <!-- Headline: current / max (or just current when max is unknown) -->
- <div class="flex items-baseline gap-1.5">
- <span class="text-lg font-mono font-semibold">{fmt(usage.current)}</span>
- {#if usage.max !== null}
- <span class="text-xs text-base-content/50 font-mono">/ {fmt(usage.max)}</span>
- {/if}
- <span class="text-xs text-base-content/40 ml-1">tokens</span>
- </div>
-
- {#if usage.percent !== null}
- <progress
- class="progress w-full h-2 mt-1.5 {fillClass(usage.percent)}"
- value={usage.percent}
- max="100"
- ></progress>
- {:else}
- <p class="text-xs text-base-content/40 mt-1.5">
- Max context size unknown for this model.
- </p>
- {/if}
-
- {#if modelId}
- <div class="text-xs text-base-content/40 mt-1.5 truncate" title={modelId}>
- {modelId}
- </div>
- {/if}
- </div>
-
- <p class="text-xs text-base-content/40">
- Current context = the most recent request's prompt + output (what the
- model actually held in its window that turn). Grows as the conversation
- gets longer. Resets on reload.
- </p>
- {/if}
-</div>
diff --git a/packages/frontend/src/lib/components/DebugPanel.svelte b/packages/frontend/src/lib/components/DebugPanel.svelte
deleted file mode 100644
index aea1ccb..0000000
--- a/packages/frontend/src/lib/components/DebugPanel.svelte
+++ /dev/null
@@ -1,35 +0,0 @@
-<script lang="ts">
-import { tabStore } from "../tabs.svelte.js";
-
-let copyLabel = $state("Copy conversation");
-
-function resetCopyLabel(): void {
- copyLabel = "Copy conversation";
-}
-
-async function handleCopy(): Promise<void> {
- const text = tabStore.copyConversation();
- try {
- await navigator.clipboard.writeText(text);
- copyLabel = "Copied";
- } catch {
- copyLabel = "Failed";
- }
- setTimeout(resetCopyLabel, 1500);
-}
-</script>
-
-<div class="flex flex-col gap-3">
- <div class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Debug</div>
-
- <div class="flex flex-col gap-2">
- <p class="text-xs text-base-content/70">Conversation</p>
- <p class="text-xs text-base-content/40">
- Copy a structured plain-text dump of the active tab's conversation
- (chunk shape included) for bug reports.
- </p>
- <button type="button" class="btn btn-sm btn-primary w-full" onclick={handleCopy}>
- {copyLabel}
- </button>
- </div>
-</div>
diff --git a/packages/frontend/src/lib/components/Header.svelte b/packages/frontend/src/lib/components/Header.svelte
deleted file mode 100644
index 72a6ebf..0000000
--- a/packages/frontend/src/lib/components/Header.svelte
+++ /dev/null
@@ -1,27 +0,0 @@
-<script lang="ts">
-import ListIcon from "phosphor-svelte/lib/ListIcon";
-import { router } from "../router.svelte.js";
-import { wsClient } from "../ws.svelte.js";
-
-const { onToggleSidebar }: { onToggleSidebar: () => void } = $props();
-</script>
-
-<header class="navbar bg-base-200 px-4 min-h-14 flex-shrink-0">
- <div class="navbar-start">
- <button class="text-xl font-bold tracking-tight btn btn-ghost px-0" onclick={() => router.navigate("dashboard")}>Dispatch</button>
- </div>
- <div class="navbar-end flex items-center gap-3">
- <span class="flex items-center gap-1.5 text-xs text-base-content/60">
- <span class="status status-sm {wsClient.connectionStatus === 'connected' ? 'status-success' : wsClient.connectionStatus === 'connecting' ? 'status-warning' : 'status-error'}"></span>
- <span class="capitalize">{wsClient.connectionStatus}</span>
- </span>
- <button
- type="button"
- class="btn btn-square btn-sm btn-neutral"
- onclick={onToggleSidebar}
- aria-label="Toggle sidebar"
- >
- <ListIcon size={20} weight="bold" aria-hidden="true" />
- </button>
- </div>
-</header>
diff --git a/packages/frontend/src/lib/components/HotReloadIndicator.svelte b/packages/frontend/src/lib/components/HotReloadIndicator.svelte
deleted file mode 100644
index 8d37578..0000000
--- a/packages/frontend/src/lib/components/HotReloadIndicator.svelte
+++ /dev/null
@@ -1,35 +0,0 @@
-<script lang="ts">
-const { active }: { active: boolean } = $props();
-
-let visible = $state(false);
-let timer: ReturnType<typeof setTimeout> | null = null;
-
-$effect(() => {
- if (active) {
- visible = true;
- if (timer !== null) clearTimeout(timer);
- timer = setTimeout(() => {
- visible = false;
- timer = null;
- }, 2000);
- }
- return () => {
- if (timer !== null) {
- clearTimeout(timer);
- timer = null;
- }
- visible = false;
- };
-});
-</script>
-
-{#if visible}
- <div
- class="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-info/20 text-info text-xs font-medium animate-pulse"
- role="status"
- aria-live="polite"
- >
- <span class="status status-xs status-info"></span>
- <span>Config reloaded</span>
- </div>
-{/if}
diff --git a/packages/frontend/src/lib/components/KeyUsage.svelte b/packages/frontend/src/lib/components/KeyUsage.svelte
deleted file mode 100644
index 7c0cadc..0000000
--- a/packages/frontend/src/lib/components/KeyUsage.svelte
+++ /dev/null
@@ -1,477 +0,0 @@
-<script lang="ts">
-import type { KeyInfo, KeyUsageData, UsageBucket } from "../types.js";
-
-const {
- keys = [],
- apiBase = "",
-}: {
- keys?: KeyInfo[];
- apiBase?: string;
-} = $props();
-
-interface KeyUsageEntry {
- keyId: string;
- provider: string;
- data: KeyUsageData | null;
- error: string | null;
- loading: boolean;
-}
-
-// In-memory cache for the current session (backend DB is the persistent cache)
-const usageCache = new Map<string, KeyUsageData>();
-
-function buildEntries(keyList: KeyInfo[]): KeyUsageEntry[] {
- return keyList.map((k) => {
- const cached = usageCache.get(k.id);
- return {
- keyId: k.id,
- provider: k.provider,
- data: cached ?? null,
- error: null,
- loading: true, // always show spinner during refresh
- };
- });
-}
-
-let entries = $state<KeyUsageEntry[]>([]);
-
-async function fetchOne(key: KeyInfo) {
- try {
- const res = await fetch(`${apiBase}/models/key-usage?keyId=${encodeURIComponent(key.id)}`);
- if (!res.ok) {
- const data = await res.json().catch(() => ({}));
- updateEntry(key.id, {
- error: data.error ?? `HTTP ${res.status}`,
- loading: false,
- });
- } else {
- const fresh = (await res.json()) as KeyUsageData;
- usageCache.set(key.id, fresh);
- updateEntry(key.id, {
- data: fresh,
- error: null,
- loading: false,
- });
- }
- } catch (e) {
- updateEntry(key.id, {
- error: e instanceof Error ? e.message : "Failed to fetch",
- loading: false,
- });
- }
-}
-
-function updateEntry(
- keyId: string,
- patch: { data?: KeyUsageData | null; error?: string | null; loading?: boolean },
-) {
- entries = entries.map((e) => (e.keyId === keyId ? { ...e, ...patch } : e));
-}
-
-// Sync entries with keys reactively — runs before DOM update so
-// cached data renders on first paint without a flash of empty state.
-$effect.pre(() => {
- entries = buildEntries(keys);
-});
-
-// Fetch data and set up 90s auto-refresh
-$effect(() => {
- const currentKeys = keys;
- // Fire all fetches in parallel
- for (const key of currentKeys) {
- fetchOne(key);
- }
-
- // Refresh every 90s
- const interval = setInterval(() => {
- for (const key of currentKeys) {
- updateEntry(key.id, { loading: true });
- fetchOne(key);
- }
- }, 90_000);
-
- return () => clearInterval(interval);
-});
-
-// Merge duplicate Claude entries — all anthropic keys return the same
-// set of accounts, so collect and deduplicate under one "Claude" card.
-const claudeAccounts = $derived.by(() => {
- const seen = new Set<string>();
- const accounts: Array<{
- label: string;
- source: string;
- subscriptionType?: string;
- fiveHour?: UsageBucket;
- sevenDay?: UsageBucket;
- error?: string;
- }> = [];
- const claudeEntries = entries.filter((e) => e.provider === "anthropic");
- for (const e of claudeEntries) {
- if (!e.data || e.data.provider !== "anthropic" || !e.data.accounts) continue;
- for (const acct of e.data.accounts) {
- if (!seen.has(acct.source)) {
- seen.add(acct.source);
- accounts.push(acct);
- }
- }
- }
- return accounts;
-});
-
-const claudeLoading = $derived(entries.some((e) => e.provider === "anthropic" && e.loading));
-const claudeError = $derived(
- entries.find((e) => e.provider === "anthropic" && e.error)?.error ?? null,
-);
-
-const nonClaudeEntries = $derived(entries.filter((e) => e.provider !== "anthropic"));
-
-function progressClass(utilization: number): string {
- if (utilization > 0.8) return "progress-error";
- if (utilization >= 0.5) return "progress-warning";
- return "progress-success";
-}
-
-// Pace-aware coloring for cycle bars that show a "time dot" (elapsed % of the
-// reset window). Red once usage hits 90%, otherwise green when usage is at or
-// behind the dot and orange when it has run ahead of it. Falls back to the
-// plain threshold coloring when no dot is present (elapsedPct < 0).
-function pacedProgressClass(percentUsed: number, elapsedPct: number): string {
- if (percentUsed >= 90) return "progress-error";
- if (elapsedPct < 0) return progressClass(percentUsed / 100);
- if (percentUsed <= elapsedPct) return "progress-success";
- return "progress-warning";
-}
-
-function formatDate(ts: number): string {
- const diff = ts - Date.now();
- const days = Math.floor(diff / 86400000);
- const d = new Date(ts);
- const dateStr =
- d.toLocaleDateString("en-US", { month: "2-digit", day: "2-digit" }) +
- " " +
- d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
-
- if (diff <= 0) {
- return d.toLocaleString();
- }
- if (diff < 48 * 60 * 60 * 1000) {
- const hours = Math.floor(diff / 3600000);
- const minutes = Math.floor((diff % 3600000) / 60000);
- return `in ${hours}:${String(minutes).padStart(2, "0")}`;
- }
- if (days <= 30) {
- const weeks = Math.floor(days / 7);
- const remDays = days % 7;
- if (weeks > 0 && remDays > 0) {
- return `in ${weeks}w ${remDays}d (${dateStr})`;
- }
- if (weeks > 0) {
- return `in ${weeks} week${weeks > 1 ? "s" : ""} (${dateStr})`;
- }
- return `in ${days} day${days > 1 ? "s" : ""} (${dateStr})`;
- }
- return d.toLocaleDateString("en-US", {
- month: "2-digit",
- day: "2-digit",
- year: "numeric",
- });
-}
-
-function formatKeyId(id: string): string {
- if (/^github-copilot/i.test(id)) return "Copilot";
- return id
- .split("-")
- .map((part) => {
- if (part.toLowerCase() === "opencode") return "OpenCode";
- return part.charAt(0).toUpperCase() + part.slice(1);
- })
- .join(" ");
-}
-
-// Cycle durations in ms
-const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
-const SEVEN_DAY_MS = 7 * 24 * 60 * 60 * 1000;
-const THIRTY_DAY_MS = 30 * 24 * 60 * 60 * 1000;
-
-function cycleElapsedPct(resetsAt: number | undefined, cycleDurationMs: number): number {
- if (!resetsAt) return -1;
- const timeRemaining = resetsAt - Date.now();
- const elapsed = cycleDurationMs - timeRemaining;
- return Math.max(0, Math.min(100, Math.round((elapsed / cycleDurationMs) * 100)));
-}
-
-function hasBucketData(bucket: UsageBucket | undefined): boolean {
- return bucket !== undefined && bucket.utilization !== undefined;
-}
-</script>
-
-<div class="flex flex-col gap-3 flex-1 min-h-0">
- {#if keys.length === 0}
- <p class="text-xs text-base-content/50">No keys available.</p>
- {:else}
- <div class="flex flex-col gap-3 flex-1 min-h-0 overflow-y-auto">
- <!-- Claude (all accounts merged under one card) -->
- {#if claudeAccounts.length > 0 || claudeLoading || claudeError}
- <div class="bg-base-200 rounded-lg p-2">
- <div class="flex items-center gap-1.5 mb-1.5">
- <span class="text-xs font-semibold">Claude</span>
- <span class="badge badge-xs badge-ghost">anthropic</span>
- {#if claudeLoading}
- <span class="loading loading-spinner loading-xs"></span>
- {/if}
- </div>
-
- {#if claudeAccounts.length === 0 && claudeLoading}
- <div class="flex items-center gap-1.5 py-1">
- <span class="text-xs text-base-content/50">Loading...</span>
- </div>
- {:else if claudeAccounts.length === 0 && claudeError}
- <div role="alert" class="text-xs text-error/80">{claudeError}</div>
- {:else}
- {#if claudeError}
- <div role="alert" class="text-xs text-error/80 mb-1">{claudeError}</div>
- {/if}
- {#each claudeAccounts as acct, idx (acct.source)}
- {#if idx > 0}
- <div class="border-t border-base-300 my-1.5"></div>
- {/if}
- <div class="flex flex-col gap-1 pl-1">
- <div class="flex items-center gap-1">
- <span class="text-xs font-medium">{acct.label}</span>
- {#if acct.subscriptionType}
- <span class="badge badge-xs">{acct.subscriptionType}</span>
- {/if}
- </div>
- {#if acct.error}
- <p class="text-xs text-error/70">{acct.error}</p>
- {/if}
- {#if hasBucketData(acct.fiveHour)}
- {@const b = acct.fiveHour!}
- {@const u = b.utilization ?? 0}
- {@const p = Math.round(u * 100)}
- {@const tp = cycleElapsedPct(b.resetsAt, FIVE_HOUR_MS)}
- <div class="flex flex-col gap-0.5">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">5-Hour</span>
- <span class="text-xs font-mono">{p}%</span>
- </div>
- <div class="relative w-full h-2">
- <progress class="progress w-full h-2 {pacedProgressClass(p, tp)} absolute inset-0" value={p} max="100"></progress>
- {#if tp >= 0}
- <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div>
- {/if}
- </div>
- {#if b.resetsAt}
- <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span>
- {/if}
- </div>
- {/if}
- {#if hasBucketData(acct.sevenDay)}
- {@const b = acct.sevenDay!}
- {@const u = b.utilization ?? 0}
- {@const p = Math.round(u * 100)}
- {@const tp = cycleElapsedPct(b.resetsAt, SEVEN_DAY_MS)}
- <div class="flex flex-col gap-0.5">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Weekly</span>
- <span class="text-xs font-mono">{p}%</span>
- </div>
- <div class="relative w-full h-2">
- <progress class="progress w-full h-2 {pacedProgressClass(p, tp)} absolute inset-0" value={p} max="100"></progress>
- {#if tp >= 0}
- <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div>
- {/if}
- </div>
- {#if b.resetsAt}
- <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span>
- {/if}
- </div>
- {/if}
- </div>
- {/each}
- {/if}
- </div>
- {/if}
-
- <!-- Non-Claude keys -->
- {#each nonClaudeEntries as entry (entry.keyId)}
- <div class="bg-base-200 rounded-lg p-2">
- <div class="flex items-center gap-1.5 mb-1.5">
- <span class="text-xs font-semibold">{formatKeyId(entry.keyId)}</span>
- <span class="badge badge-xs badge-ghost">{entry.provider}</span>
- {#if entry.loading}
- <span class="loading loading-spinner loading-xs"></span>
- {/if}
- </div>
-
- {#if entry.loading && !entry.data}
- <div class="flex items-center gap-1.5 py-1">
- <span class="loading loading-spinner loading-xs"></span>
- <span class="text-xs text-base-content/50">Loading...</span>
- </div>
- {:else}
- {#if entry.error}
- <div role="alert" class="text-xs text-error/80 mb-1">{entry.error}</div>
- {/if}
- {#if !entry.data}
- <p class="text-xs text-base-content/50">No data.</p>
-
- {:else if entry.data.provider === "opencode-go"}
- {#if entry.data.unavailable}
- <p class="text-xs text-base-content/70">Usage data not available. Set OPENCODE_COOKIE env var to enable.</p>
- {#if entry.data.limits}
- <div class="text-xs text-base-content/50 mt-1">
- Limits: {entry.data.limits.fiveHour}/5h &middot; {entry.data.limits.weekly}/wk &middot; {entry.data.limits.monthly}/mo
- </div>
- {/if}
- {#if entry.data.consoleUrl}
- <a href={entry.data.consoleUrl} target="_blank" rel="noopener noreferrer" class="link link-primary text-xs mt-1">
- View usage on opencode.ai
- </a>
- {/if}
- {:else}
- {#if hasBucketData(entry.data.fiveHour)}
- {@const b = entry.data.fiveHour!}
- {@const u = b.utilization ?? 0}
- {@const p = Math.round(u * 100)}
- {@const tp = cycleElapsedPct(b.resetsAt, FIVE_HOUR_MS)}
- <div class="flex flex-col gap-0.5">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">5-Hour</span>
- <span class="text-xs font-mono">{p}%</span>
- </div>
- <div class="relative w-full h-2">
- <progress class="progress w-full h-2 {pacedProgressClass(p, tp)} absolute inset-0" value={p} max="100"></progress>
- {#if tp >= 0}
- <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div>
- {/if}
- </div>
- {#if b.resetsAt}
- <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span>
- {/if}
- </div>
- {/if}
- {#if hasBucketData(entry.data.weekly)}
- {@const b = entry.data.weekly!}
- {@const u = b.utilization ?? 0}
- {@const p = Math.round(u * 100)}
- {@const tp = cycleElapsedPct(b.resetsAt, SEVEN_DAY_MS)}
- <div class="flex flex-col gap-0.5">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Weekly</span>
- <span class="text-xs font-mono">{p}%</span>
- </div>
- <div class="relative w-full h-2">
- <progress class="progress w-full h-2 {pacedProgressClass(p, tp)} absolute inset-0" value={p} max="100"></progress>
- {#if tp >= 0}
- <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div>
- {/if}
- </div>
- {#if b.resetsAt}
- <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span>
- {/if}
- </div>
- {/if}
- {#if hasBucketData(entry.data.monthly)}
- {@const b = entry.data.monthly!}
- {@const u = b.utilization ?? 0}
- {@const p = Math.round(u * 100)}
- {@const tp = cycleElapsedPct(b.resetsAt, THIRTY_DAY_MS)}
- <div class="flex flex-col gap-0.5">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Monthly</span>
- <span class="text-xs font-mono">{p}%</span>
- </div>
- <div class="relative w-full h-2">
- <progress class="progress w-full h-2 {pacedProgressClass(p, tp)} absolute inset-0" value={p} max="100"></progress>
- {#if tp >= 0}
- <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div>
- {/if}
- </div>
- {#if b.resetsAt}
- <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span>
- {/if}
- </div>
- {/if}
- {/if}
-
- {:else if entry.data.provider === "github-copilot"}
- {@const p = Math.round(entry.data.percentUsed ?? 0)}
- <div class="flex flex-col gap-0.5 pl-1">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">
- {#if entry.data.tokensConsumed !== undefined && entry.data.tokensRemaining !== undefined}
- {entry.data.tokensConsumed.toLocaleString()} / {(entry.data.tokensConsumed + entry.data.tokensRemaining).toLocaleString()} tokens
- {:else if entry.data.plan}
- {entry.data.plan}
- {:else}
- Usage
- {/if}
- </span>
- <span class="text-xs font-mono">{p}%</span>
- </div>
- <progress class="progress w-full h-2 {progressClass(p / 100)}" value={p} max="100"></progress>
- {#if entry.data.resetAt}
- <span class="text-xs text-base-content/40">Resets: {formatDate(entry.data.resetAt)}</span>
- {/if}
- </div>
- {:else if entry.data.provider === "google"}
- <div class="flex flex-col gap-0.5 pl-1">
- <!-- Cookie-scraped usage from gemini.google.com -->
- {#if entry.data.currentUsage}
- {@const u = entry.data.currentUsage}
- <div class="flex flex-col gap-0.5">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Current</span>
- <span class="text-xs font-mono">{u.percentUsed}%</span>
- </div>
- <progress class="progress w-full h-2 {progressClass(u.percentUsed / 100)}" value={u.percentUsed} max="100"></progress>
- {#if u.resetsAt}
- <span class="text-xs text-base-content/40">Resets: {u.resetsAt}</span>
- {/if}
- </div>
- {/if}
- {#if entry.data.weeklyUsage}
- {@const w = entry.data.weeklyUsage}
- <div class="flex flex-col gap-0.5">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Weekly</span>
- <span class="text-xs font-mono">{w.percentUsed}%</span>
- </div>
- <progress class="progress w-full h-2 {progressClass(w.percentUsed / 100)}" value={w.percentUsed} max="100"></progress>
- {#if w.resetsAt}
- <span class="text-xs text-base-content/40">Resets: {w.resetsAt}</span>
- {/if}
- </div>
- {/if}
- <!-- API key rate limits -->
- {#if !entry.data.currentUsage && entry.data.models && entry.data.models.length > 0}
- {@const m = entry.data.models[0]}
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Models</span>
- <span class="text-xs font-mono">{entry.data.models.length} available</span>
- </div>
- {#if m && m.rpm > 0}
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">RPM</span>
- <span class="text-xs font-mono">{m.rpm}</span>
- </div>
- {/if}
- {#if m && m.requestsPerDay > 0}
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">RPD</span>
- <span class="text-xs font-mono">{m.requestsPerDay.toLocaleString()}</span>
- </div>
- {/if}
- {#if !entry.data.currentUsage}
- <p class="text-xs text-base-content/40 mt-0.5">Set GEMINI_COOKIE (__Secure-1PSID) for usage %</p>
- {/if}
- {/if}
- </div>
- {/if}
- {/if}
- </div>
- {/each}
- </div>
- {/if}
-</div>
diff --git a/packages/frontend/src/lib/components/MarkdownRenderer.svelte b/packages/frontend/src/lib/components/MarkdownRenderer.svelte
deleted file mode 100644
index de202b6..0000000
--- a/packages/frontend/src/lib/components/MarkdownRenderer.svelte
+++ /dev/null
@@ -1,174 +0,0 @@
-<script lang="ts">
-import DOMPurify from "dompurify";
-import hljs from "highlight.js/lib/core";
-// Hot set — matches roughly what ChatGPT preloads. Registered eagerly so
-// common code blocks highlight on first paint without a network roundtrip.
-import bash from "highlight.js/lib/languages/bash";
-import c from "highlight.js/lib/languages/c";
-import cpp from "highlight.js/lib/languages/cpp";
-import csharp from "highlight.js/lib/languages/csharp";
-import css from "highlight.js/lib/languages/css";
-import go from "highlight.js/lib/languages/go";
-import java from "highlight.js/lib/languages/java";
-import javascript from "highlight.js/lib/languages/javascript";
-import json from "highlight.js/lib/languages/json";
-import markdown from "highlight.js/lib/languages/markdown";
-import php from "highlight.js/lib/languages/php";
-import plaintext from "highlight.js/lib/languages/plaintext";
-import python from "highlight.js/lib/languages/python";
-import ruby from "highlight.js/lib/languages/ruby";
-import rust from "highlight.js/lib/languages/rust";
-import shell from "highlight.js/lib/languages/shell";
-import sql from "highlight.js/lib/languages/sql";
-import typescript from "highlight.js/lib/languages/typescript";
-import xml from "highlight.js/lib/languages/xml";
-import yaml from "highlight.js/lib/languages/yaml";
-import { Marked } from "marked";
-import { markedHighlight } from "marked-highlight";
-
-hljs.registerLanguage("bash", bash);
-hljs.registerLanguage("c", c);
-hljs.registerLanguage("cpp", cpp);
-hljs.registerLanguage("csharp", csharp);
-hljs.registerLanguage("css", css);
-hljs.registerLanguage("go", go);
-hljs.registerLanguage("java", java);
-hljs.registerLanguage("javascript", javascript);
-hljs.registerLanguage("json", json);
-hljs.registerLanguage("markdown", markdown);
-hljs.registerLanguage("php", php);
-hljs.registerLanguage("plaintext", plaintext);
-hljs.registerLanguage("python", python);
-hljs.registerLanguage("ruby", ruby);
-hljs.registerLanguage("rust", rust);
-hljs.registerLanguage("shell", shell);
-hljs.registerLanguage("sql", sql);
-hljs.registerLanguage("typescript", typescript);
-hljs.registerLanguage("xml", xml);
-hljs.registerLanguage("yaml", yaml);
-
-// Normalize common aliases to their canonical highlight.js language names.
-// The canonical name is what we'll attempt to dynamically import.
-const ALIASES: Record<string, string> = {
- js: "javascript",
- jsx: "javascript",
- mjs: "javascript",
- cjs: "javascript",
- ts: "typescript",
- tsx: "typescript",
- py: "python",
- py3: "python",
- rb: "ruby",
- sh: "bash",
- shell: "bash",
- zsh: "bash",
- yml: "yaml",
- "c++": "cpp",
- cxx: "cpp",
- "c#": "csharp",
- cs: "csharp",
- htm: "xml",
- html: "xml",
- svg: "xml",
- md: "markdown",
- mdx: "markdown",
- golang: "go",
- rs: "rust",
- kt: "kotlin",
- ps1: "powershell",
-};
-
-function normalizeLang(lang: string): string {
- const lower = lang.toLowerCase().trim();
- return ALIASES[lower] ?? lower;
-}
-
-const loadCache = new Map<string, Promise<boolean>>();
-
-async function ensureLanguage(lang: string): Promise<boolean> {
- const name = normalizeLang(lang);
- if (hljs.getLanguage(name)) return true;
- if (loadCache.has(name)) return loadCache.get(name) ?? false;
-
- const promise = (async () => {
- try {
- // Dynamic import for languages not in the hot set above.
- // @vite-ignore: the variable `name` is intentionally dynamic;
- // missing modules are caught by the try/catch below.
- const mod = await import(/* @vite-ignore */ `highlight.js/lib/languages/${name}`);
- hljs.registerLanguage(name, mod.default);
- return true;
- } catch {
- return false;
- }
- })();
-
- loadCache.set(name, promise);
- return promise;
-}
-
-function escapeHtml(s: string): string {
- return s
- .replace(/&/g, "&amp;")
- .replace(/</g, "&lt;")
- .replace(/>/g, "&gt;")
- .replace(/"/g, "&quot;")
- .replace(/'/g, "&#39;");
-}
-
-const md = new Marked(
- markedHighlight({
- emptyLangClass: "hljs",
- langPrefix: "hljs language-",
- async: true,
- async highlight(code: string, lang: string): Promise<string> {
- if (!lang) return escapeHtml(code);
- const name = normalizeLang(lang);
- const loaded = await ensureLanguage(name);
- if (!loaded) return escapeHtml(code);
- try {
- return hljs.highlight(code, { language: name, ignoreIllegals: true }).value;
- } catch {
- return escapeHtml(code);
- }
- },
- }),
- {
- gfm: true,
- breaks: true,
- },
-);
-
-const { text = "", streaming = false }: { text?: string; streaming?: boolean } = $props();
-
-function closeOpenDelimiters(src: string): string {
- let out = src;
- const fenceCount = (out.match(/^```/gm) || []).length;
- if (fenceCount % 2 !== 0) out += "\n```";
- const boldCount = (out.match(/\*\*/g) || []).length;
- if (boldCount % 2 !== 0) out += "**";
- const inlineCode = (out.match(/(?<!`)`(?!`)/g) || []).length;
- if (inlineCode % 2 !== 0) out += "`";
- return out;
-}
-
-let html = $state("");
-let renderToken = 0;
-
-$effect(() => {
- const src = streaming ? closeOpenDelimiters(text) : text;
- const myToken = ++renderToken;
- (async () => {
- try {
- const raw = (await md.parse(src)) as string;
- if (myToken === renderToken) html = DOMPurify.sanitize(raw);
- } catch {
- // swallow — keeps last successful render visible
- }
- })();
-});
-</script>
-
-<div class="markdown-body">
- {@html html}
-</div>
diff --git a/packages/frontend/src/lib/components/ModelSelector.svelte b/packages/frontend/src/lib/components/ModelSelector.svelte
deleted file mode 100644
index 64036d4..0000000
--- a/packages/frontend/src/lib/components/ModelSelector.svelte
+++ /dev/null
@@ -1,636 +0,0 @@
-<script module lang="ts">
-const modelCache = new Map<string, string[]>();
-</script>
-
-<script lang="ts">
- import {
- DEFAULT_REASONING_EFFORT,
- isReasoningEffort,
- REASONING_EFFORTS,
- REASONING_EFFORT_LABELS,
- } from "@dispatch/core/src/types/index.js";
- import type { KeyInfo } from "../types.js";
- import {
- cacheWarming,
- WARM_INTERVAL_MS,
- } from "../cache-warming.svelte.js";
- import { config } from "../config.js";
- import { router } from "../router.svelte.js";
- import { tabStore } from "../tabs.svelte.js";
-
- interface AgentInfo {
- name: string;
- slug: string;
- scope: string;
- description: string;
- skills: string[];
- tools: string[];
- models: Array<{ key_id: string; model_id: string; effort?: string }>;
- cwd?: string;
- is_subagent?: boolean;
- }
-
- // Moves an element to document.body so modals escape the sidebar's
- // transform stacking context and cover the full viewport.
- function portal(node: HTMLElement) {
- document.body.appendChild(node);
- return {
- destroy() {
- node.remove();
- },
- };
- }
-
- /**
- * Human-readable effort label for a (possibly-unset) per-model effort. When
- * the model has no explicit override, the badge reflects what will ACTUALLY
- * run: the per-tab selector if valid, else the system default. This mirrors
- * the backend resolution order (per-model → per-tab → default) so the UI
- * never misrepresents the effective effort.
- */
- function effortLabel(effort: string | undefined): string {
- if (isReasoningEffort(effort)) return REASONING_EFFORT_LABELS[effort];
- const tab = isReasoningEffort(reasoningEffort) ? reasoningEffort : DEFAULT_REASONING_EFFORT;
- return REASONING_EFFORT_LABELS[tab];
- }
-
- const {
- keys = [],
- activeTabId = null,
- activeKeyId = null,
- activeModelId = null,
- reasoningEffort = "max",
- activeAgentSlug = null,
- activeTabParentId = null as string | null,
- activeAgentModels = null as Array<{ key_id: string; model_id: string; effort?: string }> | null,
- workingDirectory = null,
- onKeyChange,
- onModelChange,
- onReasoningChange,
- onAgentChange = (_agent: AgentInfo | null) => {},
- onWorkingDirectoryChange = (_dir: string | null) => {},
- onCompact = () => {},
- canCompact = false,
- compacting = false,
- }: {
- keys?: KeyInfo[];
- activeTabId?: string | null;
- activeKeyId?: string | null;
- activeModelId?: string | null;
- reasoningEffort?: string;
- activeAgentSlug?: string | null;
- activeTabParentId?: string | null;
- activeAgentModels?: Array<{ key_id: string; model_id: string; effort?: string }> | null;
- workingDirectory?: string | null;
- onKeyChange: (keyId: string) => void;
- onModelChange: (keyId: string, modelId: string) => void;
- onReasoningChange: (effort: string) => void;
- onAgentChange?: (agent: AgentInfo | null) => void;
- onWorkingDirectoryChange?: (dir: string | null) => void;
- onCompact?: () => void;
- canCompact?: boolean;
- compacting?: boolean;
- } = $props();
-
- let showKeyModal = $state(false);
- let showModelModal = $state(false);
- let availableModels = $state<string[]>([]);
- let loadingModels = $state(false);
- let modelError = $state<string | null>(null);
- let sliderDragging = $state<number | null>(null);
- let modelSearch = $state("");
-
- // ─── Prompt-cache warming (debug strip lives at the bottom) ──────
- // Reactive per-tab warming state from the singleton store. `warm.now` is a
- // 1s ticking clock so the countdown re-renders while a fire is pending.
- const warm = $derived(cacheWarming.stateFor(activeTabId));
- const warmCountdown = $derived.by(() => {
- const next = warm.nextFireAt;
- if (next === null) return null;
- const ms = Math.max(0, next - cacheWarming.now);
- const total = Math.round(ms / 1000);
- const m = Math.floor(total / 60);
- const s = total % 60;
- return `${m}:${s.toString().padStart(2, "0")}`;
- });
- const warmIntervalLabel = `${Math.round(WARM_INTERVAL_MS / 60000)} min`;
-
- function toggleCacheWarming(enabled: boolean): void {
- if (!activeTabId) return;
- tabStore.setCacheWarmingEnabled(activeTabId, enabled);
- }
-
- let cwdExists = $state<boolean | null>(null);
- let cwdCheckTimer: ReturnType<typeof setTimeout> | null = null;
-
- $effect(() => {
- const cwd = workingDirectory;
- if (!cwd) {
- cwdExists = null;
- return;
- }
- cwdExists = null;
- if (cwdCheckTimer) clearTimeout(cwdCheckTimer);
- cwdCheckTimer = setTimeout(async () => {
- try {
- const res = await fetch(
- `${config.apiBase}/agents/check-dir?path=${encodeURIComponent(cwd)}`,
- );
- if (res.ok) {
- const data = await res.json();
- cwdExists = data.exists ?? false;
- }
- } catch {
- cwdExists = null;
- }
- }, 300);
- });
-
- let modeOverride = $state<"manual" | "agent" | null>(null);
- let mode = $derived(
- activeTabParentId && activeAgentSlug
- ? "subagent"
- : modeOverride ?? (activeAgentSlug ? "agent" : "manual"),
- );
- let agents = $state<AgentInfo[]>([]);
- let visibleAgents = $derived(agents.filter((a) => !a.is_subagent));
- let loadingAgents = $state(false);
-
- $effect(() => {
- fetchAgents();
- });
-
- async function fetchAgents() {
- loadingAgents = true;
- try {
- const res = await fetch(`${config.apiBase}/agents`);
- if (res.ok) {
- const data = await res.json();
- agents = data.agents ?? [];
- }
- } catch {
- /* ignore */
- } finally {
- loadingAgents = false;
- }
- }
-
- function selectKey(keyId: string) {
- showKeyModal = false;
- onKeyChange(keyId);
- // Immediately open model selection for the new key
- openModelModal(keyId);
- }
-
- async function openModelModal(keyIdOverride?: string) {
- const keyId = keyIdOverride ?? activeKeyId;
- if (!keyId) return;
- showModelModal = true;
- modelError = null;
- modelSearch = "";
-
- // Check session cache
- if (modelCache.has(keyId)) {
- availableModels = modelCache.get(keyId)!;
- loadingModels = false;
- return;
- }
-
- loadingModels = true;
- availableModels = [];
-
- try {
- const res = await fetch(
- `${config.apiBase}/models/available?keyId=${encodeURIComponent(keyId)}`,
- );
- if (!res.ok) {
- const data = await res.json().catch(() => ({}));
- modelError = data.error ?? `Failed to fetch models (HTTP ${res.status})`;
- return;
- }
- const data = await res.json();
- availableModels = data.models ?? [];
- // Cache for session
- modelCache.set(keyId, availableModels);
- } catch (err) {
- modelError = err instanceof Error ? err.message : "Failed to fetch models";
- } finally {
- loadingModels = false;
- }
- }
-
- function selectModel(model: string) {
- showModelModal = false;
- if (activeKeyId) {
- onModelChange(activeKeyId, model);
- }
- }
-</script>
-
-<div class="bg-base-200 rounded-lg p-3">
- <!-- Working Directory -->
- <div class="form-control mb-3">
- <label class="label py-0" for="cwd-input">
- <span class="label-text text-xs font-semibold">Working Directory</span>
- </label>
- <div class="flex items-center gap-1.5 mt-1">
- <input
- id="cwd-input"
- type="text"
- class="input input-bordered input-sm font-mono text-xs flex-1"
- placeholder="default (project root)"
- value={workingDirectory ?? ""}
- onchange={(e) => {
- const val = e.currentTarget.value.trim();
- onWorkingDirectoryChange(val || null);
- }}
- />
- {#if workingDirectory}
- {#if cwdExists === true}
- <span class="text-success text-sm" title="Directory exists">&#x2714;</span>
- {:else if cwdExists === false}
- <span class="text-warning text-sm" title="Will be created">&#x2716;</span>
- {:else}
- <span class="loading loading-spinner loading-xs"></span>
- {/if}
- {/if}
- </div>
- </div>
-
- <!-- Compact conversation -->
- <div class="mb-3">
- <button
- type="button"
- class="btn btn-sm btn-outline w-full"
- disabled={!canCompact || compacting}
- onclick={onCompact}
- title="Summarize older turns into a compact anchor, preserving the most recent turns. Opens a new tab while it works; the conversation continues here once done."
- >
- {#if compacting}
- <span class="loading loading-spinner loading-xs"></span>
- Compacting…
- {:else}
- Compact conversation
- {/if}
- </button>
- </div>
-
- <!-- Toggle -->
- <div class="flex items-center gap-2 mb-3">
- <button
- class="btn btn-xs {mode === 'manual' ? 'btn-primary' : 'btn-ghost'}"
- onclick={() => { modeOverride = "manual"; onAgentChange(null); }}
- disabled={mode === 'subagent'}
- >
- Manual
- </button>
- <button
- class="btn btn-xs {mode === 'agent' ? 'btn-primary' : 'btn-ghost'}"
- onclick={async () => {
- modeOverride = "agent";
- await fetchAgents();
- // Re-apply the active agent's settings (including cwd)
- const current = visibleAgents.find(a => a.slug === activeAgentSlug);
- const agentToApply = current ?? visibleAgents[0] ?? null;
- if (agentToApply) {
- onAgentChange(agentToApply);
- // Force-update the input since the prop may not change (already set)
- const cwdEl = document.getElementById("cwd-input") as HTMLInputElement | null;
- if (cwdEl) cwdEl.value = agentToApply.cwd ?? "";
- }
- }}
- disabled={mode === 'subagent'}
- >
- Agent
- </button>
- <button
- class="btn btn-xs {mode === 'subagent' ? 'btn-primary' : 'btn-ghost'}"
- disabled={true}
- >
- SubAgent
- </button>
- </div>
-
- {#if mode === "manual"}
- <div class="flex items-center justify-between">
- <span class="text-sm font-medium">Key</span>
- <button class="btn btn-sm btn-outline" onclick={() => (showKeyModal = true)}>
- {activeKeyId ?? "Select Key"}
- </button>
- </div>
-
- <div class="flex items-center justify-between mt-2">
- <span class="text-sm font-medium">Model</span>
- <button class="btn btn-sm btn-outline" onclick={() => openModelModal()} disabled={!activeKeyId}>
- {activeModelId ?? "Select Model"}
- </button>
- </div>
-
- {#if activeModelId}
- <div class="flex items-center justify-between mt-2">
- <span class="text-sm font-medium">Thinking</span>
- <select
- class="select select-bordered select-sm"
- value={reasoningEffort}
- onchange={(e) => onReasoningChange(e.currentTarget.value)}
- >
- {#each REASONING_EFFORTS as effort}
- <option value={effort}>{REASONING_EFFORT_LABELS[effort]}</option>
- {/each}
- </select>
- </div>
- {/if}
- {:else if mode === "subagent"}
- <!-- SubAgent read-only info -->
- {@const subModels = activeAgentModels ?? []}
- {@const hasSubModels = subModels.length > 1}
- {@const subActiveIdx = subModels.findIndex(
- (m) => m.key_id === activeKeyId && m.model_id === activeModelId,
- )}
- <div class="flex flex-col gap-2">
- <div class="flex items-center justify-between">
- <span class="text-sm font-medium">SubAgent</span>
- <span class="badge badge-sm">{activeAgentSlug}</span>
- </div>
- <div class="flex items-center justify-between">
- <span class="text-sm font-medium">Key</span>
- <span class="font-mono text-xs">{activeKeyId ?? "default"}</span>
- </div>
- <div class="flex items-center justify-between">
- <span class="text-sm font-medium">Model</span>
- <span class="font-mono text-xs">{activeModelId ?? "default"}</span>
- </div>
- {#if hasSubModels}
- {@const displayIdx = subActiveIdx >= 0 ? subActiveIdx : 0}
- <div class="mt-1 pt-2 border-t border-base-content/20">
- <div class="text-xs font-semibold mb-1">Model fallback chain</div>
- <input
- type="range"
- min="0"
- max={subModels.length - 1}
- value={displayIdx}
- class="range range-xs"
- step="1"
- disabled
- />
- <div class="flex w-full justify-between px-0.5 text-xs opacity-50 mt-0.5">
- {#each subModels as _, i}
- <span>{i + 1}</span>
- {/each}
- </div>
- <div class="mt-1 flex flex-col gap-0.5">
- {#each subModels as m, i}
- <div class="text-xs font-mono truncate flex items-center gap-1 {i === displayIdx ? 'opacity-100 font-semibold' : 'opacity-50'}">
- <span class="truncate">{i + 1}. {m.key_id} / {m.model_id}</span>
- <span class="badge badge-xs badge-ghost shrink-0">{effortLabel(m.effort)}</span>
- </div>
- {/each}
- </div>
- </div>
- {/if}
- <p class="text-xs text-base-content/50 mt-1">This tab was spawned by a parent agent. Settings cannot be changed.</p>
- </div>
- {:else}
- <!-- Agent selection UI -->
- {#if loadingAgents}
- <div class="flex items-center gap-2 py-2 text-base-content/60">
- <span class="loading loading-spinner loading-xs"></span>
- Loading agents...
- </div>
- {:else if visibleAgents.length === 0}
- <p class="text-base-content/50 text-sm py-2">No agents configured.</p>
- {:else}
- <div class="flex flex-col gap-1.5">
- {#each visibleAgents as agent (agent.slug + ":" + agent.scope)}
- {@const isActive = activeAgentSlug === agent.slug}
- {@const hasMultipleModels = agent.models.length > 1}
- {@const currentIdx = isActive
- ? agent.models.findIndex(
- (m) => m.key_id === activeKeyId && m.model_id === activeModelId,
- )
- : -1}
- <div
- role="button"
- tabindex="0"
- class="w-full text-left rounded-lg px-3 py-2 transition-colors {isActive ? 'bg-primary text-primary-content' : 'bg-base-300 hover:bg-base-200'}"
- onclick={() => {
- // Only switch agent — don't reset the slider position
- onAgentChange(agent);
- const cwdEl = document.getElementById("cwd-input") as HTMLInputElement | null;
- if (cwdEl) cwdEl.value = agent.cwd ?? "";
- }}
- onkeydown={(e) => {
- if (e.key === "Enter" || e.key === " ") {
- e.preventDefault();
- onAgentChange(agent);
- const cwdEl = document.getElementById("cwd-input") as HTMLInputElement | null;
- if (cwdEl) cwdEl.value = agent.cwd ?? "";
- }
- }}
- >
- <div class="flex items-center justify-between gap-2">
- <span class="font-medium text-sm">{agent.name}</span>
- <div class="flex gap-1 shrink-0">
- <span class="badge badge-xs">{agent.models.length} model{agent.models.length !== 1 ? "s" : ""}</span>
- <span class="badge badge-xs badge-outline">{agent.scope === "global" ? "global" : "project"}</span>
- </div>
- </div>
- {#if agent.description}
- <p class="text-xs opacity-60 mt-0.5">{agent.description}</p>
- {/if}
- {#if isActive && hasMultipleModels}
- {@const displayIdx = sliderDragging !== null ? sliderDragging : (currentIdx >= 0 ? currentIdx : 0)}
- {@const displayModel = agent.models[displayIdx]}
- <div class="mt-2 pt-2 border-t border-primary-content/20">
- <div class="text-xs font-semibold mb-1 truncate flex items-center gap-1">
- <span class="truncate">{displayModel ? `${displayModel.key_id} / ${displayModel.model_id}` : `${activeKeyId} / ${activeModelId}`}</span>
- <span class="badge badge-xs shrink-0">{effortLabel(displayModel?.effort)}</span>
- </div>
- <input
- type="range"
- min="0"
- max={agent.models.length - 1}
- value={currentIdx >= 0 ? currentIdx : 0}
- class="range range-xs"
- step="1"
- oninput={(e) => {
- sliderDragging = Number(e.currentTarget.value);
- }}
- onchange={(e) => {
- const idx = Number(e.currentTarget.value);
- const m = agent.models[idx];
- if (m) onModelChange(m.key_id, m.model_id);
- sliderDragging = null;
- }}
- onclick={(e) => e.stopPropagation()}
- onkeydown={(e) => e.stopPropagation()}
- />
- <div class="flex w-full justify-between px-0.5 text-xs opacity-50 mt-0.5">
- {#each agent.models as _, i}
- <span>{i + 1}</span>
- {/each}
- </div>
- </div>
- {/if}
- </div>
- {/each}
- </div>
- {/if}
-
- <button
- type="button"
- class="btn btn-outline btn-sm w-full mt-2 hover:bg-base-300 hover:border-base-300 text-base-content/60"
- onclick={() => router.navigate("agent-builder")}
- >
- Agent Settings
- </button>
- {/if}
-
- <!-- Prompt-cache warming (bottom of the Chat Settings panel) -->
- <div class="mt-3 pt-3 border-t border-base-300">
- <label class="flex items-center gap-2 cursor-pointer">
- <input
- type="checkbox"
- class="checkbox checkbox-sm rounded-sm"
- checked={warm.enabled}
- disabled={!activeTabId}
- onchange={(e) => toggleCacheWarming(e.currentTarget.checked)}
- />
- <span class="text-xs font-semibold">Keep prompt cache warm</span>
- </label>
- <p class="text-[10px] text-base-content/40 mt-1 leading-snug">
- While this tab is idle, replays the cached conversation every {warmIntervalLabel}
- so the provider cache stays warm for your next message. Warming traffic is
- debug-only — it never touches history, the Cache Rate metric, or context size.
- </p>
-
- {#if warm.enabled}
- <div class="mt-2 flex flex-col gap-2 bg-base-300/40 rounded-lg p-2">
- <!-- Warming "last request" cache rate (separate from the real metric) -->
- <div class="flex flex-col gap-0.5">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Last request (warming)</span>
- <span class="text-xs font-mono">{warm.lastPct === null ? "-%" : `${warm.lastPct}%`}</span>
- </div>
- <progress
- class="progress w-full h-2 {warm.lastPct === null
- ? ''
- : warm.lastPct >= 70
- ? 'progress-success'
- : warm.lastPct >= 30
- ? 'progress-warning'
- : 'progress-error'}"
- value={warm.lastPct ?? 0}
- max="100"
- ></progress>
- </div>
-
- <!-- Countdown to the next warming fire -->
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Next warm in</span>
- <span class="text-xs font-mono">
- {#if warm.firing}
- warming…
- {:else if warmCountdown !== null}
- {warmCountdown}
- {:else}
- —
- {/if}
- </span>
- </div>
-
- {#if warm.error}
- <div class="text-[10px] text-error break-words">
- {warm.error}
- </div>
- {/if}
- </div>
- {/if}
- </div>
-</div>
-
-{#if showKeyModal}
- <div class="modal modal-open" use:portal>
- <div class="modal-box">
- <h3 class="font-bold text-xl">Select Key</h3>
- <div class="mt-4 flex flex-col gap-2">
- {#each keys as key}
- <button
- class="btn {key.id === activeKeyId
- ? 'btn-primary'
- : 'btn-ghost'} justify-start text-base"
- onclick={() => selectKey(key.id)}
- >
- <span class="font-mono">{key.id}</span>
- <span class="badge ml-auto">{key.provider}</span>
- <span
- class="badge {key.status === 'active'
- ? 'badge-success'
- : 'badge-error'}">{key.status}</span
- >
- </button>
- {/each}
- </div>
- <div class="modal-action">
- <button class="btn" onclick={() => (showKeyModal = false)}>Cancel</button>
- </div>
- </div>
- <button type="button" class="modal-backdrop" onclick={() => (showKeyModal = false)} aria-label="Close modal"></button>
- </div>
-{/if}
-
-{#if showModelModal}
- <div class="modal modal-open" use:portal>
- <div class="modal-box">
- <h3 class="font-bold text-xl">Select Model</h3>
- {#if loadingModels}
- <div class="flex justify-center py-8">
- <span class="loading loading-spinner loading-lg"></span>
- </div>
- {:else if modelError}
- <div class="alert alert-error mt-4 text-base">
- <span>{modelError}</span>
- </div>
- {:else}
- {@const search = modelSearch.toLowerCase().trim()}
- {@const searchRegex = search
- ? new RegExp(
- search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/ /g, "[ _-]"),
- )
- : null}
- {@const filteredModels = searchRegex
- ? availableModels.filter((m) => searchRegex.test(m.toLowerCase()))
- : availableModels}
- <div class="mt-4 flex flex-col gap-1">
- <input
- type="text"
- class="input input-bordered input-sm w-full"
- placeholder="Filter models..."
- bind:value={modelSearch}
- />
- <div class="mt-2 max-h-96 overflow-y-auto flex flex-col gap-1">
- {#each filteredModels as model}
- <button
- class="btn {model === activeModelId
- ? 'btn-primary'
- : 'btn-ghost'} justify-start font-mono text-base"
- onclick={() => selectModel(model)}
- >
- {model}
- </button>
- {/each}
- {#if filteredModels.length === 0}
- <p class="text-xs text-base-content/50 py-2 text-center">
- {search ? 'No models match your search.' : 'No models available.'}
- </p>
- {/if}
- </div>
- </div>
- {/if}
- <div class="modal-action">
- <button class="btn" onclick={() => (showModelModal = false)}>Cancel</button>
- </div>
- </div>
- <button type="button" class="modal-backdrop" onclick={() => (showModelModal = false)} aria-label="Close modal"></button>
- </div>
-{/if}
diff --git a/packages/frontend/src/lib/components/ModelStatus.svelte b/packages/frontend/src/lib/components/ModelStatus.svelte
deleted file mode 100644
index 57c5efd..0000000
--- a/packages/frontend/src/lib/components/ModelStatus.svelte
+++ /dev/null
@@ -1,356 +0,0 @@
-<script lang="ts">
-interface KeyInfo {
- id: string;
- provider: string;
- status: "active" | "exhausted";
- lastError: string | null;
- exhaustedAt: number | null;
-}
-
-interface CredentialStatus {
- keyId: string;
- provider: string;
- subscriptionType: string | null;
- importedAt: number;
- updatedAt: number;
- expired: boolean;
-}
-
-const {
- keys = [],
- currentModel,
- apiBase = "",
- onAddKey = () => {},
-}: {
- keys?: KeyInfo[];
- currentModel?: string;
- apiBase?: string;
- onAddKey?: () => void;
-} = $props();
-
-const activeKeys = $derived(keys.filter((k) => k.status === "active").length);
-const totalKeys = $derived(keys.length);
-const allActive = $derived(totalKeys > 0 && activeKeys === totalKeys);
-const allExhausted = $derived(totalKeys > 0 && activeKeys === 0);
-const someExhausted = $derived(totalKeys > 0 && activeKeys < totalKeys && activeKeys > 0);
-
-let credentialStatus = $state<Record<string, CredentialStatus>>({});
-let importingKey = $state<string | null>(null);
-let importError = $state<string | null>(null);
-let importSuccess = $state<string | null>(null);
-
-let apiKeyStatus = $state<
- Record<string, { keyId: string; provider: string; importedAt: number; updatedAt: number }>
->({});
-let showKeyModal = $state<string | null>(null); // keyId or null
-let keyModalValue = $state("");
-let keyModalError = $state<string | null>(null);
-let keyModalSaving = $state(false);
-let removingKey = $state<string | null>(null);
-
-async function loadCredentialStatus(): Promise<void> {
- try {
- const res = await fetch(`${apiBase}/models/credentials-status`);
- if (!res.ok) return;
- const data = (await res.json()) as { credentials: CredentialStatus[] };
- const map: Record<string, CredentialStatus> = {};
- for (const cred of data.credentials) {
- map[cred.keyId] = cred;
- }
- credentialStatus = map;
- } catch {
- // ignore
- }
-}
-
-async function importCredentials(keyId: string): Promise<void> {
- importingKey = keyId;
- importError = null;
- importSuccess = null;
- try {
- const res = await fetch(`${apiBase}/models/import-credentials`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ keyId }),
- });
- const data = (await res.json()) as { success?: boolean; error?: string };
- if (!res.ok || !data.success) {
- importError = data.error ?? "Import failed";
- } else {
- importSuccess = keyId;
- await loadCredentialStatus();
- setTimeout(() => {
- importSuccess = null;
- }, 3000);
- }
- } catch (e) {
- importError = e instanceof Error ? e.message : "Network error";
- } finally {
- importingKey = null;
- }
-}
-
-async function loadApiKeyStatus(): Promise<void> {
- try {
- const res = await fetch(`${apiBase}/models/api-keys-status`);
- if (!res.ok) return;
- const data = (await res.json()) as {
- keys: Array<{ keyId: string; provider: string; importedAt: number; updatedAt: number }>;
- };
- const map: Record<string, (typeof data.keys)[0]> = {};
- for (const k of data.keys) {
- map[k.keyId] = k;
- }
- apiKeyStatus = map;
- } catch {
- // ignore
- }
-}
-
-async function saveApiKey(): Promise<void> {
- if (!showKeyModal || !keyModalValue.trim()) return;
- keyModalSaving = true;
- keyModalError = null;
- try {
- const res = await fetch(`${apiBase}/models/set-api-key`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ keyId: showKeyModal, apiKey: keyModalValue.trim() }),
- });
- const data = (await res.json()) as { success?: boolean; error?: string };
- if (!res.ok || !data.success) {
- keyModalError = data.error ?? "Failed to save";
- } else {
- showKeyModal = null;
- keyModalValue = "";
- await loadApiKeyStatus();
- }
- } catch (e) {
- keyModalError = e instanceof Error ? e.message : "Network error";
- } finally {
- keyModalSaving = false;
- }
-}
-
-async function removeKey(keyId: string): Promise<void> {
- if (!confirm(`Remove key "${keyId}" from config?`)) return;
- removingKey = keyId;
- try {
- const res = await fetch(`${apiBase}/models/remove-key`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ id: keyId }),
- });
- const data = (await res.json()) as { success?: boolean; error?: string };
- if (res.ok && data.success) {
- await new Promise((r) => setTimeout(r, 500));
- window.location.reload();
- }
- } catch {
- // ignore
- } finally {
- removingKey = null;
- }
-}
-
-$effect(() => {
- void loadCredentialStatus();
- void loadApiKeyStatus();
-});
-
-function timeAgo(ts: number | null): string {
- if (ts === null) return "";
- const diffMs = Date.now() - ts;
- const diffSec = Math.floor(diffMs / 1000);
- if (diffSec < 60) return `${diffSec}s ago`;
- const diffMin = Math.floor(diffSec / 60);
- if (diffMin < 60) return `${diffMin}m ago`;
- const diffHr = Math.floor(diffMin / 60);
- return `${diffHr}h ago`;
-}
-
-function truncate(str: string | null, max: number): string {
- if (!str) return "";
- return str.length > max ? `${str.slice(0, max)}...` : str;
-}
-</script>
-
-<div class="flex flex-col gap-3">
- {#if keys.length === 0}
- <p class="text-xs text-base-content/50">
- No models configured. Using environment defaults.
- </p>
- {:else}
- <!-- Overall status -->
- {#if allActive}
- <div class="flex items-center gap-1.5">
- <span class="badge badge-success badge-xs">●</span>
- <span class="text-xs text-success">All keys available</span>
- </div>
- {:else if allExhausted}
- <div class="flex items-center gap-1.5">
- <span class="badge badge-error badge-xs">●</span>
- <span class="text-xs text-error">All keys exhausted — waiting for refresh</span>
- </div>
- {:else if someExhausted}
- <div class="flex items-center gap-1.5">
- <span class="badge badge-warning badge-xs">●</span>
- <span class="text-xs text-warning">
- Fallback active ({activeKeys}/{totalKeys} keys available)
- </span>
- </div>
- {/if}
-
- <!-- Current model -->
- {#if currentModel}
- <div class="flex flex-col gap-0.5">
- <p class="text-xs text-base-content/50 uppercase tracking-wide">Current Model</p>
- <p class="text-sm font-mono font-semibold text-primary">{currentModel}</p>
- </div>
- {/if}
-
- <!-- Import error/success banners -->
- {#if importError}
- <div role="alert" class="text-xs text-error/80">{importError}</div>
- {/if}
- {#if importSuccess}
- <div class="text-xs text-success/80">Imported credentials for {importSuccess}</div>
- {/if}
-
- <!-- Keys -->
- {#if keys.length > 0}
- <div class="flex flex-col gap-1">
- <p class="text-xs text-base-content/50 uppercase tracking-wide">API Keys</p>
- <ul class="flex flex-col gap-1">
- {#each keys as key (key.id)}
- {@const cred = credentialStatus[key.id]}
- <li class="flex flex-col gap-0.5 rounded p-1 hover:bg-base-200 transition-colors">
- <div class="flex items-center gap-1.5">
- <span
- class="badge badge-xs {key.status === 'active'
- ? 'badge-success'
- : 'badge-error'}"
- >
- {key.status}
- </span>
- <span class="text-xs font-mono flex-1">{key.id}</span>
- <span class="text-xs text-base-content/40">{key.provider}</span>
- <button
- type="button"
- class="btn btn-xs btn-ghost btn-square text-base-content/30 hover:text-error"
- disabled={removingKey === key.id}
- onclick={() => removeKey(key.id)}
- title="Remove key"
- >
- {#if removingKey === key.id}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- ✕
- {/if}
- </button>
- </div>
- {#if key.status === "exhausted"}
- <div class="pl-2 flex flex-col gap-0.5">
- {#if key.lastError}
- <p class="text-xs text-error/70 line-clamp-1">
- {truncate(key.lastError, 80)}
- </p>
- {/if}
- {#if key.exhaustedAt !== null}
- <p class="text-xs text-base-content/40">{timeAgo(key.exhaustedAt)}</p>
- {/if}
- </div>
- {/if}
- <!-- Credential import for anthropic keys -->
- {#if key.provider === "anthropic"}
- <div class="pl-2 flex items-center gap-1.5 mt-0.5">
- {#if cred}
- <span class="badge badge-xs {cred.expired ? 'badge-warning' : 'badge-info'}">
- {cred.expired ? "expired" : "imported"}
- </span>
- {#if cred.subscriptionType}
- <span class="text-xs text-base-content/40">{cred.subscriptionType}</span>
- {/if}
- {/if}
- <button
- type="button"
- class="btn btn-xs btn-ghost btn-outline"
- disabled={importingKey === key.id}
- onclick={() => importCredentials(key.id)}
- >
- {#if importingKey === key.id}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- {cred ? "Re-import" : "Import Credentials"}
- {/if}
- </button>
- </div>
- {/if}
- <!-- API key import for env-based keys (opencode, copilot, etc) -->
- {#if key.provider !== "anthropic"}
- <div class="pl-2 flex items-center gap-1.5 mt-0.5">
- {#if apiKeyStatus[key.id]}
- <span class="badge badge-xs badge-info">imported</span>
- {/if}
- <button
- type="button"
- class="btn btn-xs btn-ghost btn-outline"
- onclick={() => { showKeyModal = key.id; keyModalValue = ""; keyModalError = null; }}
- >
- {apiKeyStatus[key.id] ? "Update Key" : "Import Key"}
- </button>
- </div>
- {/if}
- </li>
- {/each}
- </ul>
- </div>
- {/if}
- {/if}
- <button
- type="button"
- class="btn btn-sm btn-primary btn-outline w-full mt-2"
- onclick={onAddKey}
- >
- + Add New Key
- </button>
-<!-- Import Key Modal -->
-{#if showKeyModal}
- <div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" role="dialog">
- <div class="bg-base-100 rounded-lg p-4 w-80 flex flex-col gap-3 shadow-xl">
- <h3 class="text-sm font-semibold">Import Key: {showKeyModal}</h3>
- <input
- type="password"
- class="input input-bordered input-sm w-full"
- placeholder="Paste API key..."
- bind:value={keyModalValue}
- />
- {#if keyModalError}
- <p class="text-xs text-error">{keyModalError}</p>
- {/if}
- <div class="flex justify-end gap-2">
- <button
- type="button"
- class="btn btn-sm btn-ghost"
- onclick={() => { showKeyModal = null; keyModalValue = ""; keyModalError = null; }}
- >
- Cancel
- </button>
- <button
- type="button"
- class="btn btn-sm btn-primary"
- disabled={!keyModalValue.trim() || keyModalSaving}
- onclick={saveApiKey}
- >
- {#if keyModalSaving}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- Save
- {/if}
- </button>
- </div>
- </div>
- </div>
-{/if}
-
-</div>
diff --git a/packages/frontend/src/lib/components/PermissionPrompt.svelte b/packages/frontend/src/lib/components/PermissionPrompt.svelte
deleted file mode 100644
index 3a67b30..0000000
--- a/packages/frontend/src/lib/components/PermissionPrompt.svelte
+++ /dev/null
@@ -1,100 +0,0 @@
-<script lang="ts">
-import type { PermissionPrompt } from "../types.js";
-
-const {
- pending,
- onReply,
-}: {
- pending: PermissionPrompt[];
- onReply: (id: string, reply: "once" | "always" | "reject") => void;
-} = $props();
-
-let current = $derived(pending[0]);
-
-let showAlwaysConfirmation = $state(false);
-
-let dialogEl: HTMLDialogElement | undefined = $state();
-
-$effect(() => {
- if (!dialogEl) return;
- if (current) {
- if (!dialogEl.open) dialogEl.showModal();
- } else {
- if (dialogEl.open) dialogEl.close();
- }
-});
-
-function handleAlways() {
- showAlwaysConfirmation = true;
-}
-
-function confirmAlways() {
- if (current) onReply(current.id, "always");
- showAlwaysConfirmation = false;
-}
-
-function handleOnce() {
- if (current) onReply(current.id, "once");
-}
-
-function handleReject() {
- showAlwaysConfirmation = false;
- if (current) onReply(current.id, "reject");
-}
-</script>
-
-<dialog class="modal" bind:this={dialogEl} oncancel={handleReject}>
- {#if current}
- {#if !showAlwaysConfirmation}
- <div class="modal-box">
- <h3 class="text-lg font-bold">
- {#if current.permission === "bash"}
- Run command
- {:else if current.permission === "external_directory"}
- Access external directory
- {:else if current.permission === "read"}
- Read file
- {:else if current.permission === "edit"}
- Edit file
- {:else}
- Permission required
- {/if}
- </h3>
-
- <p class="py-2 text-sm opacity-70">{current.description}</p>
-
- {#if current.permission === "bash" && current.metadata.command}
- <div class="mockup-code my-2">
- <pre><code>$ {current.metadata.command as string}</code></pre>
- </div>
- {/if}
-
- {#if current.metadata.filepath}
- <p class="text-sm font-mono">{current.metadata.filepath as string}</p>
- {/if}
-
- <div class="modal-action gap-2">
- <button class="btn btn-sm btn-ghost" aria-label="Deny permission" onclick={handleReject}>Deny</button>
- <button class="btn btn-sm" aria-label="Allow once" onclick={handleOnce}>Allow once</button>
- <button class="btn btn-sm btn-primary" aria-label="Always allow" onclick={handleAlways}>Always allow</button>
- </div>
- </div>
- {:else}
- <div class="modal-box">
- <h3 class="text-lg font-bold">Always allow?</h3>
- <p class="py-2 text-sm">
- The following patterns will be permanently allowed until you restart Dispatch:
- </p>
- <div class="mockup-code my-2">
- {#each current.always as pattern}
- <pre><code>{pattern}</code></pre>
- {/each}
- </div>
- <div class="modal-action gap-2">
- <button class="btn btn-sm btn-ghost" aria-label="Go back" onclick={() => showAlwaysConfirmation = false}>Back</button>
- <button class="btn btn-sm btn-primary" aria-label="Confirm always allow" onclick={confirmAlways}>Confirm</button>
- </div>
- </div>
- {/if}
- {/if}
-</dialog>
diff --git a/packages/frontend/src/lib/components/SettingsPanel.svelte b/packages/frontend/src/lib/components/SettingsPanel.svelte
deleted file mode 100644
index 8b28957..0000000
--- a/packages/frontend/src/lib/components/SettingsPanel.svelte
+++ /dev/null
@@ -1,643 +0,0 @@
-<script lang="ts">
-import { config } from "../config.js";
-import { appSettings } from "../settings.svelte.js";
-import { applyTheme, loadStoredTheme, THEMES, type Theme } from "../theme.js";
-import type { KeyInfo } from "../types.js";
-
-const {
- keys = [],
- apiBase = "",
-}: {
- keys?: KeyInfo[];
- apiBase?: string;
-} = $props();
-
-// Theme picker — was a header-triggered modal (`ThemeSwitcher.svelte`);
-// inlined here so theme picking lives in Settings alongside other UI
-// preferences. Theme constants and apply/persist live in `../theme.ts`
-// so the boot-time apply in `App.svelte` and this picker can't drift.
-let currentTheme = $state<Theme>(loadStoredTheme());
-
-function selectTheme(theme: Theme): void {
- currentTheme = theme;
- applyTheme(theme);
-}
-
-let titleKeyId = $state<string | null>(null);
-let titleModelId = $state<string | null>(null);
-let availableModels = $state<string[]>([]);
-let compactionKeyId = $state<string | null>(null);
-let compactionModelId = $state<string | null>(null);
-let compactionModels = $state<string[]>([]);
-let loadingCompactionModels = $state(false);
-let loadingModels = $state(false);
-let autoExpandThinking = $state(appSettings.autoExpandThinking);
-let localChunkLimit = $state(appSettings.chunkLimit);
-let backendUrl = $state(config.apiBase);
-let backendUrlSaved = $state(false);
-
-// ─── ntfy.sh push notifications ──────────────────────────────────
-// Server-side schema mirror — kept inline rather than imported to avoid
-// pulling a node-only barrel into the browser bundle (frontend already
-// hand-mirrors a few core types in lib/types.ts for the same reason).
-type NotificationEventType =
- | "turn-completed"
- | "turn-error"
- | "permission-required"
- | "agent-spawned";
-
-interface NtfyConfigView {
- enabled: boolean;
- topic: string;
- authToken: string;
- hasAuthToken?: boolean;
- events: Record<NotificationEventType, boolean>;
- notifySubagents: boolean;
-}
-
-const NTFY_EVENT_LABELS: Record<NotificationEventType, string> = {
- "turn-completed": "Turn completed",
- "turn-error": "Turn error",
- "permission-required": "Permission requested",
- "agent-spawned": "User agent spawned",
-};
-
-const DEFAULT_NTFY: NtfyConfigView = {
- enabled: false,
- topic: "",
- authToken: "",
- hasAuthToken: false,
- events: {
- "turn-completed": true,
- "turn-error": true,
- "permission-required": true,
- "agent-spawned": false,
- },
- notifySubagents: false,
-};
-
-let ntfy = $state<NtfyConfigView>({ ...DEFAULT_NTFY, events: { ...DEFAULT_NTFY.events } });
-let ntfyAuthTokenInput = $state(""); // empty == leave unchanged on save
-let ntfyEventOrder = $state<NotificationEventType[]>([
- "turn-completed",
- "turn-error",
- "permission-required",
- "agent-spawned",
-]);
-let ntfySaving = $state(false);
-let ntfySaveError = $state<string | null>(null);
-let ntfySaveOk = $state(false);
-let ntfyTesting = $state(false);
-let ntfyTestResult = $state<string | null>(null);
-let ntfyTestOk = $state(false);
-let ntfyClearingToken = $state(false);
-
-function onChunkLimitChange(e: Event): void {
- const input = e.target as HTMLInputElement;
- const val = parseInt(input.value, 10);
- if (val >= 10 && val <= 2000) {
- appSettings.chunkLimit = val;
- localChunkLimit = val;
- }
-}
-
-function saveBackendUrl(): void {
- const trimmed = backendUrl.trim().replace(/\/+$/, "");
- if (!trimmed) return;
- config.setApiBase(trimmed);
- backendUrl = trimmed;
- backendUrlSaved = true;
- setTimeout(() => {
- backendUrlSaved = false;
- }, 2000);
-}
-
-function resetBackendUrl(): void {
- config.setApiBase(config.defaultApiBase);
- backendUrl = config.defaultApiBase;
- backendUrlSaved = true;
- setTimeout(() => {
- backendUrlSaved = false;
- }, 2000);
-}
-
-async function loadSettings(): Promise<void> {
- try {
- const res = await fetch(`${apiBase}/tabs/settings/title-model`);
- if (res.ok) {
- const data = (await res.json()) as { keyId: string | null; modelId: string | null };
- titleKeyId = data.keyId;
- if (titleKeyId) {
- await loadModelsForKey(titleKeyId);
- }
- titleModelId = data.modelId;
- }
- } catch {
- // ignore
- }
- try {
- const res = await fetch(`${apiBase}/tabs/settings/compaction-model`);
- if (res.ok) {
- const data = (await res.json()) as { keyId: string | null; modelId: string | null };
- compactionKeyId = data.keyId;
- if (compactionKeyId) {
- await loadCompactionModelsForKey(compactionKeyId);
- }
- compactionModelId = data.modelId;
- }
- } catch {
- // ignore
- }
- try {
- const res = await fetch(`${apiBase}/tabs/settings/auto-expand-thinking`);
- if (res.ok) {
- const data = (await res.json()) as { value: string | null };
- autoExpandThinking = data.value === "true";
- appSettings.autoExpandThinking = autoExpandThinking;
- }
- } catch {
- // ignore
- }
- await loadNtfy();
-}
-
-async function loadNtfy(): Promise<void> {
- try {
- const res = await fetch(`${apiBase}/notifications`);
- if (!res.ok) return;
- const data = (await res.json()) as {
- config: NtfyConfigView;
- eventTypes?: NotificationEventType[];
- };
- ntfy = {
- ...DEFAULT_NTFY,
- ...data.config,
- events: { ...DEFAULT_NTFY.events, ...(data.config.events ?? {}) },
- };
- if (Array.isArray(data.eventTypes) && data.eventTypes.length > 0) {
- ntfyEventOrder = data.eventTypes;
- }
- } catch {
- // ignore
- }
-}
-
-async function saveNtfy(): Promise<void> {
- ntfySaving = true;
- ntfySaveError = null;
- ntfySaveOk = false;
- try {
- // `authToken: undefined` ⇒ server keeps the existing token.
- // `authToken: ""` ⇒ explicit clear (the user typed and cleared).
- const payload: Partial<NtfyConfigView> & { authToken?: string } = {
- enabled: ntfy.enabled,
- topic: ntfy.topic,
- events: ntfy.events,
- notifySubagents: ntfy.notifySubagents,
- };
- if (ntfyAuthTokenInput !== "") payload.authToken = ntfyAuthTokenInput;
- const res = await fetch(`${apiBase}/notifications`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(payload),
- });
- const data = (await res.json()) as { config?: NtfyConfigView; error?: string };
- if (!res.ok) {
- ntfySaveError = data.error ?? `Save failed (HTTP ${res.status})`;
- return;
- }
- if (data.config) {
- ntfy = {
- ...DEFAULT_NTFY,
- ...data.config,
- events: { ...DEFAULT_NTFY.events, ...(data.config.events ?? {}) },
- };
- }
- ntfyAuthTokenInput = "";
- ntfySaveOk = true;
- setTimeout(() => {
- ntfySaveOk = false;
- }, 2000);
- } catch (e) {
- ntfySaveError = e instanceof Error ? e.message : "Network error";
- } finally {
- ntfySaving = false;
- }
-}
-
-async function sendNtfyTest(): Promise<void> {
- ntfyTesting = true;
- ntfyTestResult = null;
- ntfyTestOk = false;
- try {
- const res = await fetch(`${apiBase}/notifications/test`, { method: "POST" });
- const data = (await res.json()) as { ok?: boolean; error?: string; status?: number };
- if (!res.ok || !data.ok) {
- ntfyTestResult = data.error ?? `Test failed (HTTP ${res.status})`;
- return;
- }
- ntfyTestOk = true;
- ntfyTestResult = "Sent — check your ntfy client.";
- } catch (e) {
- ntfyTestResult = e instanceof Error ? e.message : "Network error";
- } finally {
- ntfyTesting = false;
- }
-}
-
-async function clearNtfyAuthToken(): Promise<void> {
- // `""` ⇒ explicit clear on save (vs. `undefined` which keeps existing).
- // Optimistic local state on failure caused a real bug pre-review: UI showed
- // the token cleared while the server still held it, then "Save" treated the
- // blank input as "keep existing" and silently re-armed the old token. Await
- // the response and only flip local state on success.
- ntfyClearingToken = true;
- ntfySaveError = null;
- try {
- const res = await fetch(`${apiBase}/notifications`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ authToken: "" }),
- });
- const data = (await res.json().catch(() => ({}))) as {
- config?: NtfyConfigView;
- error?: string;
- };
- if (!res.ok) {
- ntfySaveError = data.error ?? `Clear failed (HTTP ${res.status})`;
- return;
- }
- ntfyAuthTokenInput = "";
- if (data.config) {
- ntfy = {
- ...DEFAULT_NTFY,
- ...data.config,
- events: { ...DEFAULT_NTFY.events, ...(data.config.events ?? {}) },
- };
- } else {
- ntfy = { ...ntfy, hasAuthToken: false };
- }
- } catch (e) {
- ntfySaveError = e instanceof Error ? e.message : "Network error";
- } finally {
- ntfyClearingToken = false;
- }
-}
-
-async function toggleAutoExpand(): Promise<void> {
- autoExpandThinking = !autoExpandThinking;
- appSettings.autoExpandThinking = autoExpandThinking;
- fetch(`${apiBase}/tabs/settings/auto-expand-thinking`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ value: String(autoExpandThinking) }),
- }).catch(() => {});
-}
-
-async function loadModelsForKey(keyId: string): Promise<void> {
- loadingModels = true;
- try {
- const res = await fetch(`${apiBase}/models/available?keyId=${encodeURIComponent(keyId)}`);
- if (!res.ok) {
- availableModels = [];
- return;
- }
- const data = (await res.json()) as { models: string[] };
- availableModels = data.models ?? [];
- } catch {
- availableModels = [];
- } finally {
- loadingModels = false;
- }
-}
-
-function saveTitleModel(): void {
- fetch(`${apiBase}/tabs/settings/title-model`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ keyId: titleKeyId, modelId: titleModelId }),
- }).catch(() => {});
-}
-
-async function onKeyChange(e: Event): Promise<void> {
- const select = e.target as HTMLSelectElement;
- titleKeyId = select.value || null;
- titleModelId = null;
- availableModels = [];
- if (titleKeyId) {
- await loadModelsForKey(titleKeyId);
- }
- saveTitleModel();
-}
-
-async function onModelChange(e: Event): Promise<void> {
- const select = e.target as HTMLSelectElement;
- titleModelId = select.value || null;
- saveTitleModel();
-}
-
-async function loadCompactionModelsForKey(keyId: string): Promise<void> {
- loadingCompactionModels = true;
- try {
- const res = await fetch(`${apiBase}/models/available?keyId=${encodeURIComponent(keyId)}`);
- if (!res.ok) {
- compactionModels = [];
- return;
- }
- const data = (await res.json()) as { models: string[] };
- compactionModels = data.models ?? [];
- } catch {
- compactionModels = [];
- } finally {
- loadingCompactionModels = false;
- }
-}
-
-function saveCompactionModel(): void {
- fetch(`${apiBase}/tabs/settings/compaction-model`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ keyId: compactionKeyId, modelId: compactionModelId }),
- }).catch(() => {});
-}
-
-async function onCompactionKeyChange(e: Event): Promise<void> {
- const select = e.target as HTMLSelectElement;
- compactionKeyId = select.value || null;
- compactionModelId = null;
- compactionModels = [];
- if (compactionKeyId) {
- await loadCompactionModelsForKey(compactionKeyId);
- }
- saveCompactionModel();
-}
-
-function onCompactionModelChange(e: Event): void {
- const select = e.target as HTMLSelectElement;
- compactionModelId = select.value || null;
- saveCompactionModel();
-}
-
-$effect(() => {
- void loadSettings();
-});
-</script>
-
-<div class="flex flex-col gap-3">
- <div class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Settings</div>
-
- <div class="flex flex-col gap-2">
- <p class="text-xs text-base-content/70">Theme</p>
- <label class="text-xs text-base-content/60">
- Appearance
- <select
- class="select select-bordered select-sm w-full capitalize"
- value={currentTheme}
- onchange={(e) => selectTheme(e.currentTarget.value as Theme)}
- >
- {#each THEMES as theme (theme)}
- <option value={theme} class="capitalize">{theme}</option>
- {/each}
- </select>
- </label>
-
- <div class="divider my-0"></div>
-
- <p class="text-xs text-base-content/70">Title Generation Model</p>
- <p class="text-xs text-base-content/40">Used to generate short titles for new tabs after the first message.</p>
-
- <label class="text-xs text-base-content/60">
- Key
- <select class="select select-bordered select-sm w-full" onchange={onKeyChange} value={titleKeyId ?? ""}>
- <option value="">Select a key...</option>
- {#each keys as key (key.id)}
- <option value={key.id}>{key.id} ({key.provider})</option>
- {/each}
- </select>
- </label>
-
- <label class="text-xs text-base-content/60">
- Model
- <select
- class="select select-bordered select-sm w-full"
- onchange={onModelChange}
- value={titleModelId ?? ""}
- disabled={!titleKeyId || loadingModels}
- >
- <option value="">{loadingModels ? "Loading models..." : "Select a model..."}</option>
- {#each availableModels as model (model)}
- <option value={model}>{model}</option>
- {/each}
- </select>
- </label>
-
- <div class="divider my-0"></div>
-
- <p class="text-xs text-base-content/70">Conversation Compaction Model</p>
- <p class="text-xs text-base-content/40">Used to summarize a conversation when you compact it. If unset, the tab's own key/model is used.</p>
-
- <label class="text-xs text-base-content/60">
- Key
- <select class="select select-bordered select-sm w-full" onchange={onCompactionKeyChange} value={compactionKeyId ?? ""}>
- <option value="">Select a key...</option>
- {#each keys as key (key.id)}
- <option value={key.id}>{key.id} ({key.provider})</option>
- {/each}
- </select>
- </label>
-
- <label class="text-xs text-base-content/60">
- Model
- <select
- class="select select-bordered select-sm w-full"
- onchange={onCompactionModelChange}
- value={compactionModelId ?? ""}
- disabled={!compactionKeyId || loadingCompactionModels}
- >
- <option value="">{loadingCompactionModels ? "Loading models..." : "Select a model..."}</option>
- {#each compactionModels as model (model)}
- <option value={model}>{model}</option>
- {/each}
- </select>
- </label>
-
- <div class="divider my-0"></div>
-
- <p class="text-xs text-base-content/70">Chat</p>
- <label class="flex items-center gap-2 cursor-pointer">
- <input
- type="checkbox"
- class="checkbox checkbox-sm rounded-sm"
- checked={autoExpandThinking}
- onchange={toggleAutoExpand}
- />
- <span class="text-xs text-base-content/70">Auto-expand thinking</span>
- </label>
-
- <div class="divider my-0"></div>
-
- <p class="text-xs text-base-content/70">Memory</p>
- <label class="flex flex-col gap-1">
- <span class="text-xs text-base-content/70">
- Max chunks in memory: <span class="font-semibold">{localChunkLimit}</span>
- </span>
- <input
- type="range"
- min="20"
- max="1000"
- step="10"
- class="range range-xs"
- value={localChunkLimit}
- oninput={onChunkLimitChange}
- />
- <span class="text-[10px] text-base-content/40">Lower = less RAM. Higher = less re-fetching.</span>
- </label>
-
- <div class="divider my-0"></div>
-
- <p class="text-xs text-base-content/70">Backend URL</p>
- <p class="text-xs text-base-content/40">API server address. Default: {config.defaultApiBase}</p>
- <div class="flex gap-1">
- <input
- type="text"
- class="input input-bordered input-sm flex-1"
- bind:value={backendUrl}
- placeholder={config.defaultApiBase}
- />
- <button type="button" class="btn btn-sm btn-primary" onclick={saveBackendUrl}>
- Save
- </button>
- </div>
- <button
- type="button"
- class="btn btn-xs btn-ghost btn-outline w-full"
- disabled={config.apiBase === config.defaultApiBase}
- onclick={resetBackendUrl}
- >
- Reset to default
- </button>
- {#if backendUrlSaved}
- <p class="text-xs text-success">Saved. Reload the page to apply.</p>
- {/if}
-
- <div class="divider my-0"></div>
-
- <p class="text-xs text-base-content/70">Notifications (ntfy.sh)</p>
- <p class="text-xs text-base-content/40">
- Push notifications to your phone when things happen here. Subscribe to your topic in the
- <a href="https://ntfy.sh/" target="_blank" rel="noopener" class="link">ntfy.sh</a> app to receive them.
- </p>
-
- <label class="flex items-center gap-2 cursor-pointer">
- <input
- type="checkbox"
- class="checkbox checkbox-sm rounded-sm"
- bind:checked={ntfy.enabled}
- />
- <span class="text-xs text-base-content/70">Enable notifications</span>
- </label>
-
- <label class="text-xs text-base-content/60 flex flex-col gap-1">
- Topic
- <input
- type="text"
- class="input input-bordered input-sm w-full"
- placeholder="your-secret-topic"
- bind:value={ntfy.topic}
- />
- <span class="text-[10px] text-base-content/40">
- Any string — pick something unguessable, since anyone with the topic name can read your notifications. Subscribe to the same topic in the ntfy app.
- </span>
- </label>
-
- <label class="text-xs text-base-content/60 flex flex-col gap-1">
- Auth token (optional, for private ntfy servers)
- <input
- type="password"
- class="input input-bordered input-sm w-full"
- placeholder={ntfy.hasAuthToken ? "•••• (stored — type to replace)" : "Leave blank for public ntfy.sh"}
- bind:value={ntfyAuthTokenInput}
- autocomplete="off"
- />
- {#if ntfy.hasAuthToken}
- <button
- type="button"
- class="btn btn-xs btn-ghost btn-outline self-start"
- disabled={ntfyClearingToken}
- onclick={clearNtfyAuthToken}
- >
- {#if ntfyClearingToken}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- Clear stored token
- {/if}
- </button>
- {/if}
- </label>
-
- <div class="flex flex-col gap-1 mt-1">
- <span class="text-xs text-base-content/60">Notify me on:</span>
- {#each ntfyEventOrder as evType (evType)}
- <label class="flex items-center gap-2 cursor-pointer">
- <input
- type="checkbox"
- class="checkbox checkbox-sm rounded-sm"
- bind:checked={ntfy.events[evType]}
- />
- <span class="text-xs text-base-content/70">{NTFY_EVENT_LABELS[evType] ?? evType}</span>
- </label>
- {/each}
- </div>
-
- <div class="flex flex-col gap-1 mt-1">
- <label class="flex items-center gap-2 cursor-pointer">
- <input
- type="checkbox"
- class="checkbox checkbox-sm rounded-sm"
- bind:checked={ntfy.notifySubagents}
- />
- <span class="text-xs text-base-content/70">Include subagent tabs</span>
- </label>
- <span class="text-[10px] text-base-content/40 pl-6">
- Off (default): turn-completed/turn-error from subagents are suppressed. Permission prompts still fire so subagents don't silently hang.
- </span>
- </div>
-
- <div class="flex gap-1 mt-1">
- <button
- type="button"
- class="btn btn-sm btn-primary flex-1"
- disabled={ntfySaving}
- onclick={saveNtfy}
- >
- {#if ntfySaving}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- Save
- {/if}
- </button>
- <button
- type="button"
- class="btn btn-sm btn-outline"
- disabled={ntfyTesting || !ntfy.enabled}
- onclick={sendNtfyTest}
- title={ntfy.enabled ? "Send a test notification with current settings" : "Enable notifications first"}
- >
- {#if ntfyTesting}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- Send test
- {/if}
- </button>
- </div>
- {#if ntfySaveOk}
- <p class="text-xs text-success">Saved.</p>
- {/if}
- {#if ntfySaveError}
- <p class="text-xs text-error">{ntfySaveError}</p>
- {/if}
- {#if ntfyTestResult}
- <p class="text-xs {ntfyTestOk ? 'text-success' : 'text-error'}">{ntfyTestResult}</p>
- {/if}
- </div>
-</div>
diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte
deleted file mode 100644
index 2003856..0000000
--- a/packages/frontend/src/lib/components/SidebarPanel.svelte
+++ /dev/null
@@ -1,220 +0,0 @@
-<script lang="ts">
-import { loadSidebarPanels, saveSidebarPanels } from "../sidebar-storage.js";
-import type { CacheStats, KeyInfo, LogEntry, TaskItem } from "../types.js";
-import CacheRatePanel from "./CacheRatePanel.svelte";
-import ClaudeReset from "./ClaudeReset.svelte";
-import ConfigPanel from "./ConfigPanel.svelte";
-import ContextWindowPanel from "./ContextWindowPanel.svelte";
-import DebugPanel from "./DebugPanel.svelte";
-import KeyUsage from "./KeyUsage.svelte";
-import ModelSelector from "./ModelSelector.svelte";
-import ModelStatus from "./ModelStatus.svelte";
-import SettingsPanel from "./SettingsPanel.svelte";
-import SkillsBrowser from "./SkillsBrowser.svelte";
-import TaskListPanel from "./TaskListPanel.svelte";
-import ToolPermissions from "./ToolPermissions.svelte";
-
-interface AgentInfo {
- slug: string;
- scope: string;
- skills: string[];
- tools: string[];
- models: Array<{ key_id: string; model_id: string }>;
- cwd?: string;
-}
-
-const {
- keys = [],
- tasks = [],
- cacheStats = null,
- cacheTabTitle = null,
- contextLimit = null,
- permissionLog = [],
- apiBase = "",
- activeTabId = null as string | null,
- activeKeyId = null,
- activeModelId = null,
- reasoningEffort = "max",
- activeAgentSlug = null as string | null,
- activeTabParentId = null as string | null,
- activeAgentModels = null as Array<{ key_id: string; model_id: string; effort?: string }> | null,
- workingDirectory = null as string | null,
- onKeyChange,
- onModelChange,
- onReasoningChange,
- onAgentChange = (_agent: AgentInfo | null) => {},
- onWorkingDirectoryChange = (_dir: string | null) => {},
- onCompact = () => {},
- canCompact = false,
- compacting = false,
- onAddKey = () => {},
-}: {
- keys?: KeyInfo[];
- tasks?: TaskItem[];
- cacheStats?: CacheStats | null;
- cacheTabTitle?: string | null;
- contextLimit?: number | null;
- permissionLog?: LogEntry[];
- apiBase?: string;
- activeTabId?: string | null;
- activeKeyId?: string | null;
- activeModelId?: string | null;
- reasoningEffort?: string;
- activeAgentSlug?: string | null;
- activeTabParentId?: string | null;
- activeAgentModels?: Array<{ key_id: string; model_id: string; effort?: string }> | null;
- workingDirectory?: string | null;
- onKeyChange: (keyId: string) => void;
- onModelChange: (keyId: string, modelId: string) => void;
- onReasoningChange: (effort: string) => void;
- onAgentChange?: (agent: AgentInfo | null) => void;
- onWorkingDirectoryChange?: (dir: string | null) => void;
- onCompact?: () => void;
- canCompact?: boolean;
- compacting?: boolean;
- onAddKey?: () => void;
-} = $props();
-
-interface Panel {
- id: number;
- selected: string;
-}
-
-// The `id` field is purely a stable key for Svelte's `{#each ... (panel.id)}`
-// block within a single session — it is NEVER persisted. Only the ordered
-// list of `selected` strings is round-tripped through localStorage; ids are
-// regenerated fresh from `nextId` on every mount.
-let nextId = 0;
-let panels = $state<Panel[]>(loadSidebarPanels().map((selected) => ({ id: nextId++, selected })));
-
-// Persist the layout whenever it changes. `$effect` re-runs whenever any
-// reactive read inside it changes; we read `panels` (the whole array) via
-// `.map`, which Svelte 5 tracks. Save errors are swallowed inside
-// `saveSidebarPanels` — best-effort.
-$effect(() => {
- saveSidebarPanels(panels.map((p) => p.selected));
-});
-
-const viewOptions = [
- "Select a view",
- "Chat Settings",
- "Key Usage",
- "Cache Rate",
- "Context Window",
- "Claude Reset",
- "Model Status",
- "Tasks",
- "Config",
- "Skills",
- "Tools",
- "Settings",
- "Debug",
-];
-
-function addPanel() {
- panels = [...panels, { id: nextId++, selected: "Select a view" }];
-}
-
-// Every panel sizes to its content; the sidebar itself (in App.svelte) is the
-// scroll container. We deliberately do NOT use `flex-1` fill here: a filled
-// panel combined with `min-h-0` lets flex shrink the panel below its content's
-// natural height, and since the content wrapper is a plain block the inner
-// scroll regions never receive a bounded height — so their bars/lists spill
-// out of the panel into neighbours or past the window edge.
-function panelClass(_selected: string): string {
- return "bg-base-200 rounded-lg p-3 flex flex-col";
-}
-
-function contentClass(_selected: string): string {
- return "mt-2";
-}
-</script>
-
-<div class="flex flex-col gap-2 min-h-0">
- {#each panels as panel, idx (panel.id)}
- <div class={panelClass(panel.selected)}>
- <div class="flex items-center gap-1">
- <select
- class="select select-bordered select-sm flex-1"
- value={panel.selected}
- onchange={(e) => {
- panels = panels.map((p) =>
- p.id === panel.id ? { ...p, selected: e.currentTarget.value } : p,
- );
- }}
- >
- {#each viewOptions as option}
- <option value={option} disabled={option === "Select a view"}>{option}</option>
- {/each}
- </select>
- {#if idx > 0}
- <button
- type="button"
- class="btn btn-sm btn-ghost btn-square shrink-0"
- aria-label="Remove panel"
- onclick={() => {
- panels = panels.filter((p) => p.id !== panel.id);
- }}
- >
- ✕
- </button>
- {/if}
- </div>
-
- <div class={contentClass(panel.selected)}>
- {#if panel.selected === "Chat Settings"}
- <ModelSelector
- {keys}
- {activeTabId}
- {activeKeyId}
- {activeModelId}
- {reasoningEffort}
- {onKeyChange}
- {onModelChange}
- {onReasoningChange}
- {activeAgentSlug}
- {activeTabParentId}
- {activeAgentModels}
- {onAgentChange}
- {workingDirectory}
- {onWorkingDirectoryChange}
- {onCompact}
- {canCompact}
- {compacting}
- />
- {:else if panel.selected === "Key Usage"}
- <KeyUsage {keys} {apiBase} />
- {:else if panel.selected === "Cache Rate"}
- <CacheRatePanel {cacheStats} tabTitle={cacheTabTitle} />
- {:else if panel.selected === "Context Window"}
- <ContextWindowPanel
- {cacheStats}
- {contextLimit}
- tabTitle={cacheTabTitle}
- modelId={activeModelId}
- />
- {:else if panel.selected === "Claude Reset"}
- <ClaudeReset {apiBase} />
- {:else if panel.selected === "Model Status"}
- <ModelStatus {keys} {apiBase} {onAddKey} />
- {:else if panel.selected === "Tasks"}
- <TaskListPanel {tasks} />
- {:else if panel.selected === "Config"}
- <ConfigPanel {apiBase} />
- {:else if panel.selected === "Skills"}
- <SkillsBrowser {apiBase} />
- {:else if panel.selected === "Tools"}
- <ToolPermissions entries={permissionLog} {apiBase} />
- {:else if panel.selected === "Settings"}
- <SettingsPanel {keys} {apiBase} />
- {:else if panel.selected === "Debug"}
- <DebugPanel />
- {/if}
- </div>
- </div>
- {/each}
-
- <button type="button" class="btn bg-base-200 hover:bg-base-300 border-none w-full text-lg" onclick={addPanel}>
- +
- </button>
-</div>
diff --git a/packages/frontend/src/lib/components/SkillsBrowser.svelte b/packages/frontend/src/lib/components/SkillsBrowser.svelte
deleted file mode 100644
index e697732..0000000
--- a/packages/frontend/src/lib/components/SkillsBrowser.svelte
+++ /dev/null
@@ -1,317 +0,0 @@
-<script lang="ts">
-import { appSettings } from "../settings.svelte.js";
-import { tabStore } from "../tabs.svelte.js";
-
-interface Skill {
- name: string;
- description: string;
- tags: string[];
- scope: "global" | "project";
- directory: string;
-}
-
-interface SkillsResponse {
- skills: Skill[];
- mappings: unknown[];
-}
-
-interface SkillDetail extends Skill {
- content: string;
- source: string;
-}
-
-interface DirGroup {
- path: string;
- label: string;
- scope: "global" | "project";
- skills: Skill[];
-}
-
-const {
- apiBase,
- checkedSkills = null,
- onSkillToggle = null,
-}: {
- apiBase: string;
- /** External checked set (agent builder mode). When null, uses appSettings. */
- checkedSkills?: Set<string> | null;
- /** Callback when a skill is toggled in external mode. */
- onSkillToggle?: ((key: string, checked: boolean) => void) | null;
-} = $props();
-
-/** Whether we're in external (agent builder) mode */
-const externalMode = $derived(checkedSkills !== null && onSkillToggle !== null);
-
-let skills = $state<Skill[]>([]);
-let loading = $state(false);
-let error = $state<string | null>(null);
-let expandedSkill = $state<string | null>(null);
-let expandedDetail = $state<SkillDetail | null>(null);
-let loadingDetail = $state(false);
-let collapsedDirs = $state<Set<string>>(new Set());
-
-async function fetchSkills() {
- loading = true;
- error = null;
- try {
- const res = await fetch(`${apiBase}/skills`);
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- const data: SkillsResponse = await res.json();
- skills = data.skills ?? [];
- } catch (e) {
- error = e instanceof Error ? e.message : "Failed to fetch skills";
- } finally {
- loading = false;
- }
-}
-
-function skillKey(skill: Skill): string {
- return `${skill.scope}:${skill.name}`;
-}
-
-/** Build a unique key for a directory group (scope + path) */
-function dirKey(group: DirGroup): string {
- return `${group.scope}:${group.path}`;
-}
-
-function isChecked(skill: Skill): boolean {
- const key = skillKey(skill);
- if (externalMode) {
- return checkedSkills?.has(key) ?? false;
- }
- return appSettings.skillChecks[key] === true;
-}
-
-function isInjected(skill: Skill): boolean {
- if (externalMode) return false;
- return tabStore.activeTab?.injectedSkills.includes(skillKey(skill)) ?? false;
-}
-
-function toggleCheck(skill: Skill): void {
- const key = skillKey(skill);
- if (externalMode) {
- onSkillToggle?.(key, !checkedSkills?.has(key));
- return;
- }
- appSettings.skillChecks = { ...appSettings.skillChecks, [key]: !isChecked(skill) };
-}
-
-function resetChecks(): void {
- if (externalMode) return;
- appSettings.skillChecks = {};
-}
-
-function toggleDir(key: string): void {
- const next = new Set(collapsedDirs);
- if (next.has(key)) next.delete(key);
- else next.add(key);
- collapsedDirs = next;
-}
-
-/** Check if a group is hidden because an ancestor directory is collapsed */
-function isHiddenByParent(group: DirGroup): boolean {
- if (!group.path.includes("/")) return false;
- // Check each ancestor path segment
- const parts = group.path.split("/");
- for (let i = 1; i < parts.length; i++) {
- const ancestorPath = parts.slice(0, i).join("/");
- const ancestorKey = `${group.scope}:${ancestorPath}`;
- if (collapsedDirs.has(ancestorKey)) return true;
- }
- return false;
-}
-
-/** Get the top-level directory for grouping spacing */
-function topLevelDir(group: DirGroup): string {
- const slash = group.path.indexOf("/");
- return slash === -1 ? group.path : group.path.slice(0, slash);
-}
-
-async function toggleExpand(skill: Skill) {
- const key = skillKey(skill);
- if (expandedSkill === key) {
- expandedSkill = null;
- expandedDetail = null;
- return;
- }
- expandedSkill = key;
- expandedDetail = null;
- loadingDetail = true;
- try {
- const res = await fetch(
- `${apiBase}/skills/${encodeURIComponent(skill.name)}?scope=${skill.scope}`,
- );
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- expandedDetail = await res.json();
- } catch {
- expandedDetail = null;
- } finally {
- loadingDetail = false;
- }
-}
-
-$effect(() => {
- fetchSkills();
-});
-
-const checkedCount = $derived(
- externalMode
- ? (checkedSkills?.size ?? 0)
- : Object.values(appSettings.skillChecks).filter((v) => v).length,
-);
-
-/** Group skills by scope + directory, sorted */
-const dirGroups = $derived.by((): DirGroup[] => {
- const map = new Map<string, DirGroup>();
- for (const skill of skills) {
- const key = `${skill.scope}:${skill.directory}`;
- let group = map.get(key);
- if (!group) {
- const label = skill.directory || "(root)";
- group = { path: skill.directory, label, scope: skill.scope, skills: [] };
- map.set(key, group);
- }
- group.skills.push(skill);
- }
- // Sort: global before project, then alphabetically by path
- const groups = Array.from(map.values());
- groups.sort((a, b) => {
- if (a.scope !== b.scope) return a.scope === "global" ? -1 : 1;
- return a.path.localeCompare(b.path);
- });
- // Sort skills within each group alphabetically
- for (const g of groups) {
- g.skills.sort((a, b) => a.name.localeCompare(b.name));
- }
- return groups;
-});
-</script>
-
-<div class="flex flex-col gap-3">
- <div class="flex items-center gap-2">
- <div class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Skills</div>
- {#if !loading}
- <span class="badge badge-sm badge-neutral">{skills.length}</span>
- {/if}
- {#if checkedCount > 0}
- <span class="badge badge-sm badge-primary">{checkedCount} {externalMode ? 'selected' : 'queued'}</span>
- {/if}
- <button
- class="btn btn-xs btn-ghost ml-auto"
- onclick={fetchSkills}
- title="Refresh skills"
- >
- Refresh
- </button>
- </div>
-
- {#if !externalMode}
- <p class="text-xs text-base-content/40">Check skills to inject with your next message.</p>
- {/if}
-
- {#if loading}
- <div class="flex items-center gap-2 py-2 text-base-content/60">
- <span class="loading loading-spinner loading-xs"></span>
- Loading skills...
- </div>
- {:else if error}
- <div class="alert alert-error text-xs py-2">{error}</div>
- {:else if skills.length === 0}
- <p class="text-base-content/50 italic py-2">
- No skills found. Create <code class="font-mono">.skills/</code> directories to get started.
- </p>
- {:else}
- <div class="flex flex-col">
- {#each dirGroups as group, idx (dirKey(group))}
- {@const collapsed = collapsedDirs.has(dirKey(group))}
- {@const hidden = isHiddenByParent(group)}
- {@const prevGroup = dirGroups[idx - 1]}
- {@const isNewTopLevel = idx === 0 || !prevGroup || topLevelDir(group) !== topLevelDir(prevGroup) || group.scope !== prevGroup.scope}
- {#if !hidden}
- {#if isNewTopLevel && idx > 0}
- <div class="divider my-1"></div>
- {/if}
- <div class="{isNewTopLevel ? '' : 'mt-0.5'}">
- <div class="rounded border border-base-content/20">
- <!-- Directory header -->
- <button
- type="button"
- class="flex items-center gap-1.5 w-full px-2 py-1.5 text-left hover:bg-base-200 transition-colors rounded-t"
- onclick={() => toggleDir(dirKey(group))}
- >
- <span class="text-xs text-base-content/40 w-3 inline-block transition-transform {collapsed ? '-rotate-90' : ''}">▼</span>
- <span class="font-mono text-xs font-semibold text-base-content/70">{group.label}</span>
- <span class="badge badge-xs {group.scope === 'global' ? 'badge-info' : 'badge-warning'}">{group.scope}</span>
- <span class="badge badge-xs badge-neutral ml-auto">{group.skills.length}</span>
- </button>
-
- <!-- Skills in this directory -->
- {#if !collapsed}
- <div class="flex flex-col gap-0.5 px-1 pb-1">
- {#each group.skills as skill (skillKey(skill))}
- {@const key = skillKey(skill)}
- {@const checked = isChecked(skill)}
- {@const injected = isInjected(skill)}
- <div
- class="rounded p-1.5 transition-colors {injected ? 'bg-primary/10 border border-primary/20' : 'hover:bg-base-200'}"
- >
- <label class="flex items-start gap-2 cursor-pointer">
- <input
- type="checkbox"
- class="checkbox checkbox-sm checkbox-primary rounded-sm mt-0.5"
- checked={checked}
- onchange={() => toggleCheck(skill)}
- />
- <div class="flex-1 min-w-0">
- <div class="flex items-center gap-1.5 flex-wrap">
- <button
- class="font-mono text-xs text-left hover:underline {injected ? 'text-primary font-semibold' : 'text-base-content'}"
- onclick={() => toggleExpand(skill)}
- >
- {skill.name}
- </button>
- {#if injected}
- <span class="badge badge-xs badge-primary">active</span>
- {/if}
- {#each skill.tags as tag}
- <span class="badge badge-xs badge-outline">{tag}</span>
- {/each}
- </div>
- {#if skill.description}
- <p class="text-xs text-base-content/50 truncate">{skill.description}</p>
- {/if}
- </div>
- </label>
-
- {#if expandedSkill === key}
- <div class="mt-2 ml-6 bg-base-300 rounded p-2">
- {#if loadingDetail}
- <span class="loading loading-spinner loading-xs text-base-content/40"></span>
- {:else if expandedDetail}
- <pre class="whitespace-pre-wrap font-mono text-xs overflow-x-auto max-h-60 overflow-y-auto">{expandedDetail.content}</pre>
- {:else}
- <p class="text-error text-xs">Failed to load skill content.</p>
- {/if}
- </div>
- {/if}
- </div>
- {/each}
- </div>
- {/if}
- </div>
- </div>
- {/if}
- {/each}
- </div>
- {/if}
-
- {#if !externalMode}
- <button
- class="btn btn-sm btn-ghost w-full"
- disabled={!appSettings.skillChecksDirty}
- onclick={resetChecks}
- >
- Reset
- </button>
- {/if}
-</div>
diff --git a/packages/frontend/src/lib/components/SystemPromptPanel.svelte b/packages/frontend/src/lib/components/SystemPromptPanel.svelte
deleted file mode 100644
index d9039f4..0000000
--- a/packages/frontend/src/lib/components/SystemPromptPanel.svelte
+++ /dev/null
@@ -1,61 +0,0 @@
-<script lang="ts">
-import { onMount } from "svelte";
-import { appSettings } from "../settings.svelte.js";
-
-const {
- apiBase = "",
-}: {
- apiBase?: string;
-} = $props();
-
-const DEFAULT_PROMPT = "You are Dispatch, a helpful AI coding assistant. Be concise and helpful.";
-
-async function loadPrompt(): Promise<void> {
- try {
- const res = await fetch(`${apiBase}/tabs/settings/system_prompt`);
- if (res.ok) {
- const data = (await res.json()) as { value: string | null };
- const value = data.value ?? DEFAULT_PROMPT;
- appSettings.systemPrompt = value;
- appSettings.savedSystemPrompt = value;
- }
- } catch {
- // ignore
- }
-}
-
-function resetPrompt(): void {
- appSettings.systemPrompt = appSettings.savedSystemPrompt;
-}
-
-const isDirty = $derived(appSettings.systemPrompt !== appSettings.savedSystemPrompt);
-
-onMount(() => {
- if (!appSettings.systemPrompt) {
- appSettings.systemPrompt = DEFAULT_PROMPT;
- appSettings.savedSystemPrompt = DEFAULT_PROMPT;
- }
- loadPrompt();
-});
-</script>
-
-<div class="flex flex-col gap-3 flex-1 min-h-0">
- <div class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">System Prompt</div>
- <p class="text-xs text-base-content/40">The base instructions sent to the AI at the start of every conversation. Tool descriptions are appended automatically. Changes are applied when you send your next message.</p>
-
- <textarea
- class="textarea textarea-bordered w-full flex-1 min-h-32 text-xs font-mono leading-relaxed"
- bind:value={appSettings.systemPrompt}
- placeholder="Enter system prompt..."
- ></textarea>
-
- <button
- class="btn btn-sm btn-ghost w-full"
- disabled={!isDirty}
- onclick={resetPrompt}
- >
- Reset
- </button>
-
- <p class="text-xs text-base-content/40">Warning: changing the system prompt will reset the AI's prompt cache for active conversations, which may increase usage costs.</p>
-</div>
diff --git a/packages/frontend/src/lib/components/TabBar.svelte b/packages/frontend/src/lib/components/TabBar.svelte
deleted file mode 100644
index 7371f7b..0000000
--- a/packages/frontend/src/lib/components/TabBar.svelte
+++ /dev/null
@@ -1,234 +0,0 @@
-<script lang="ts">
-import { tick } from "svelte";
-import type { Tab } from "../tabs.svelte.js";
-import { tabStore } from "../tabs.svelte.js";
-
-function statusColor(status: string): string {
- if (status === "running") return "bg-warning";
- if (status === "error") return "bg-error";
- return "bg-success";
-}
-
-/**
- * A tab "needs attention" — and should ping to grab the user's eye — when the
- * agent has stopped and is likely waiting on the user:
- * (a) the turn ended (idle) but the task list still has incomplete tasks
- * (pending / in_progress) — the agent probably expects a response; or
- * (b) the turn stopped due to an error of any kind.
- */
-function needsAttention(tab: Tab): boolean {
- if (tab.agentStatus === "error") return true;
- if (tab.agentStatus === "idle") {
- return tab.tasks.some((t) => t.status === "pending" || t.status === "in_progress");
- }
- return false;
-}
-
-const userTabs = $derived(tabStore.tabs.filter((t) => t.parentTabId === null));
-const subagentTabs = $derived(
- tabStore.tabs.filter((t) => t.parentTabId !== null && t.parentTabId === activeUserTabId),
-);
-const hasSubagentTabs = $derived(subagentTabs.length > 0);
-
-// When a subagent tab is active, its parent user tab should still appear selected
-const activeTab = $derived(tabStore.tabs.find((t) => t.id === tabStore.activeTabId));
-const activeUserTabId = $derived(
- activeTab?.parentTabId !== null && activeTab?.parentTabId !== undefined
- ? activeTab.parentTabId
- : tabStore.activeTabId,
-);
-
-// ── Drag-and-drop reorder (user tabs only) ──
-// Mirrors the native HTML5 DnD pattern used in AgentBuilder.svelte.
-let dragIndex = $state<number | null>(null);
-let dragOverIndex = $state<number | null>(null);
-
-function dropReorder(targetIndex: number): void {
- if (dragIndex !== null && dragIndex !== targetIndex) {
- const ids = userTabs.map((t) => t.id);
- const moved = ids.splice(dragIndex, 1)[0];
- if (moved) {
- ids.splice(targetIndex, 0, moved);
- tabStore.reorderTabs(ids);
- }
- }
- dragIndex = null;
- dragOverIndex = null;
-}
-
-// ── Double-click rename (user tabs only) ──
-let editingTabId = $state<string | null>(null);
-let editValue = $state("");
-let editInputEl = $state<HTMLInputElement | undefined>(undefined);
-
-async function startRename(tab: { id: string; title: string }): Promise<void> {
- editingTabId = tab.id;
- editValue = tab.title;
- await tick();
- editInputEl?.focus();
- editInputEl?.select();
-}
-
-function commitRename(): void {
- if (editingTabId === null) return;
- const id = editingTabId;
- editingTabId = null;
- const next = editValue.trim();
- if (next) tabStore.renameTab(id, next);
-}
-
-function cancelRename(): void {
- editingTabId = null;
-}
-
-function handleRenameKeydown(e: KeyboardEvent): void {
- if (e.key === "Enter") {
- e.preventDefault();
- commitRename();
- } else if (e.key === "Escape") {
- e.preventDefault();
- cancelRename();
- }
-}
-</script>
-
-<!-- Top row: user tabs -->
-<!-- svelte-ignore a11y_no_static_element_interactions -->
-<div
- class="overflow-x-auto bg-base-200 flex-shrink-0 {hasSubagentTabs ? '' : 'rounded-br-lg'}"
- ondblclick={(e) => { if (e.target === e.currentTarget) tabStore.createNewTab(); }}
->
- <!-- svelte-ignore a11y_no_static_element_interactions -->
- <div
- role="tablist"
- tabindex="0"
- class="tabs tabs-lift min-w-max"
- ondblclick={(e) => { if (e.target === e.currentTarget) tabStore.createNewTab(); }}
- >
- <!-- New tab button — sticky-pinned to the left edge so it stays reachable
- at any horizontal scroll; opaque bg + right-side shadow as a floating cue. -->
- <button
- type="button"
- class="tab tab-active !sticky left-0 z-10 !rounded-ss-none !border-l-0 shadow-[2px_0_4px_-1px_rgba(0,0,0,0.2)]"
- onclick={() => tabStore.createNewTab()}
- aria-label="New tab"
- >
- +
- </button>
-
- {#each userTabs as tab, i (tab.id)}
- <!-- svelte-ignore a11y_no_static_element_interactions -->
- <div
- role="tab"
- class="tab !flex items-stretch gap-1.5 {tab.id === activeUserTabId ? 'tab-active' : ''} {dragOverIndex === i ? 'bg-primary/10' : ''} {dragIndex === i ? 'opacity-50' : ''}"
- draggable={editingTabId === tab.id ? "false" : "true"}
- onclick={() => tabStore.switchTab(tab.id)}
- onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') tabStore.switchTab(tab.id); }}
- ondragstart={(e) => {
- dragIndex = i;
- if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
- }}
- ondragover={(e) => {
- e.preventDefault();
- if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
- dragOverIndex = i;
- }}
- ondragleave={() => { if (dragOverIndex === i) dragOverIndex = null; }}
- ondrop={(e) => { e.preventDefault(); dropReorder(i); }}
- ondragend={() => { dragIndex = null; dragOverIndex = null; }}
- tabindex="0"
- >
- <span class="flex items-center gap-1.5">
- {#if needsAttention(tab)}
- <span class="relative inline-grid shrink-0 *:[grid-area:1/1]">
- <span class="w-1.5 h-1.5 rounded-full animate-ping {statusColor(tab.agentStatus)}"></span>
- <span class="w-1.5 h-1.5 rounded-full {statusColor(tab.agentStatus)}"></span>
- </span>
- {:else}
- <span class="w-1.5 h-1.5 rounded-full shrink-0 {statusColor(tab.agentStatus)}"></span>
- {/if}
- <span class="font-mono text-[10px] px-1 py-0.5 rounded bg-base-300 text-base-content/60 shrink-0" title="Tab ID — agents address this tab by this handle">{tabStore.shortHandleFor(tab.id)}</span>
- {#if editingTabId === tab.id}
- <input
- bind:this={editInputEl}
- bind:value={editValue}
- class="max-w-32 text-xs bg-base-100 rounded px-1 outline-none ring-1 ring-primary/40"
- onclick={(e) => e.stopPropagation()}
- ondblclick={(e) => e.stopPropagation()}
- onkeydown={handleRenameKeydown}
- onblur={commitRename}
- />
- {:else}
- <span
- class="max-w-32 truncate text-xs"
- ondblclick={(e) => { e.stopPropagation(); startRename(tab); }}
- title="Double-click to rename"
- >{tab.title}</span>
- {/if}
- </span>
- <button
- type="button"
- class="flex items-center justify-center px-3 my-1 leading-none text-base-content/30 hover:text-error hover:bg-base-300 rounded transition-colors text-xs"
- onclick={(e) => { e.stopPropagation(); tabStore.closeTab(tab.id); }}
- aria-label="Close tab"
- >
- &#x2715;
- </button>
- </div>
- {/each}
-
- <!-- Trailing padding after the last tab. Fills remaining space (big target),
- shrinks to a small minimum when the bar overflows and scrolls.
- Double-click anywhere in it to open a new tab. -->
- <!-- svelte-ignore a11y_no_static_element_interactions -->
- <div
- class="flex-1 min-w-12 self-stretch cursor-default"
- ondblclick={() => tabStore.createNewTab()}
- title="Double-click to open a new tab"
- ></div>
- </div>
-</div>
-
-<!-- Bottom row: subagent tabs (hidden when empty) -->
-{#if hasSubagentTabs}
- <div class="overflow-x-auto bg-base-200 flex-shrink-0 border-t border-base-300 rounded-br-lg">
- <div
- role="tablist"
- class="tabs tabs-lift tabs-xs min-w-max"
- >
- {#each subagentTabs as tab (tab.id)}
- <!-- svelte-ignore a11y_no_static_element_interactions -->
- <div
- role="tab"
- class="tab !flex items-stretch gap-1 {tab.id === tabStore.activeTabId ? 'tab-active' : ''} {!tab.persistent ? 'opacity-70 italic' : ''}"
- onclick={() => tab.persistent ? tabStore.switchTab(tab.id) : tabStore.promoteTab(tab.id)}
- onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') tab.persistent ? tabStore.switchTab(tab.id) : tabStore.promoteTab(tab.id); }}
- tabindex="0"
- >
- <span class="flex items-center gap-1">
- {#if needsAttention(tab)}
- <span class="relative inline-grid shrink-0 *:[grid-area:1/1]">
- <span class="w-1 h-1 rounded-full animate-ping {statusColor(tab.agentStatus)}"></span>
- <span class="w-1 h-1 rounded-full {statusColor(tab.agentStatus)}"></span>
- </span>
- {:else}
- <span class="w-1 h-1 rounded-full shrink-0 {statusColor(tab.agentStatus)}"></span>
- {/if}
- <span class="font-mono text-[10px] px-1 rounded bg-base-300 text-base-content/60 shrink-0" title="Tab ID — agents address this tab by this handle">{tabStore.shortHandleFor(tab.id)}</span>
- <span class="max-w-28 truncate text-xs">{tab.title}</span>
- </span>
- {#if tab.persistent}
- <button
- type="button"
- class="flex items-center justify-center px-2 my-0.5 leading-none text-base-content/30 hover:text-error hover:bg-base-300 rounded transition-colors text-xs"
- onclick={(e) => { e.stopPropagation(); tabStore.closeTab(tab.id); }}
- aria-label="Close tab"
- >
- &#x2715;
- </button>
- {/if}
- </div>
- {/each}
- </div>
- </div>
-{/if}
diff --git a/packages/frontend/src/lib/components/TaskListPanel.svelte b/packages/frontend/src/lib/components/TaskListPanel.svelte
deleted file mode 100644
index 1f84bb8..0000000
--- a/packages/frontend/src/lib/components/TaskListPanel.svelte
+++ /dev/null
@@ -1,80 +0,0 @@
-<script lang="ts">
-import type { TaskItem } from "../types.js";
-
-const { tasks }: { tasks: TaskItem[] } = $props();
-
-type Status = TaskItem["status"];
-
-const completedCount = $derived(tasks.filter((t) => t.status === "completed").length);
-const inProgressCount = $derived(tasks.filter((t) => t.status === "in_progress").length);
-const cancelledCount = $derived(tasks.filter((t) => t.status === "cancelled").length);
-// "Active" total excludes cancelled items, so progress reads as work that still counts.
-const activeTotal = $derived(tasks.length - cancelledCount);
-
-function checkboxClass(status: Status): string {
- switch (status) {
- case "pending":
- return "checkbox checkbox-sm rounded-sm checkbox-secondary";
- case "in_progress":
- return "checkbox checkbox-sm rounded-sm checkbox-info";
- case "completed":
- return "checkbox checkbox-sm rounded-sm checkbox-success";
- case "cancelled":
- return "checkbox checkbox-sm rounded-sm checkbox-neutral";
- }
-}
-
-function isChecked(status: Status): boolean {
- return status === "completed";
-}
-
-function isIndeterminate(status: Status): boolean {
- return status === "in_progress";
-}
-
-function rowClass(status: Status): string {
- if (status === "completed") return "opacity-60";
- if (status === "cancelled") return "opacity-40";
- return "";
-}
-
-function textClass(status: Status): string {
- switch (status) {
- case "completed":
- return "line-through text-base-content/50";
- case "cancelled":
- return "line-through text-base-content/40";
- case "in_progress":
- return "font-semibold";
- default:
- return "";
- }
-}
-</script>
-
-<div class="flex flex-col gap-2">
- {#if tasks.length === 0}
- <p class="text-xs text-base-content/50">No tasks yet.</p>
- {:else}
- <p class="text-xs text-base-content/60">
- {completedCount}/{activeTotal} completed{#if inProgressCount > 0}, {inProgressCount} in progress{/if}{#if cancelledCount > 0}, {cancelledCount} cancelled{/if}
- </p>
- <ul class="flex flex-col gap-0.5">
- {#each tasks as task (task.id)}
- <li class="flex items-start gap-2 rounded p-1.5 transition-colors {rowClass(task.status)}">
- <input
- type="checkbox"
- class={checkboxClass(task.status)}
- checked={isChecked(task.status)}
- indeterminate={isIndeterminate(task.status)}
- disabled
- tabindex="-1"
- />
- <span class="text-xs leading-tight min-w-0 {textClass(task.status)}">
- {task.content}
- </span>
- </li>
- {/each}
- </ul>
- {/if}
-</div>
diff --git a/packages/frontend/src/lib/components/ToolCallDisplay.svelte b/packages/frontend/src/lib/components/ToolCallDisplay.svelte
deleted file mode 100644
index 1b4ebca..0000000
--- a/packages/frontend/src/lib/components/ToolCallDisplay.svelte
+++ /dev/null
@@ -1,140 +0,0 @@
-<script lang="ts">
-import { tabStore } from "../tabs.svelte.js";
-import type { ToolBatchEntry } from "../types.js";
-
-const { toolCall }: { toolCall: ToolBatchEntry } = $props();
-
-let isExpanded = $state(false);
-
-function toggle() {
- isExpanded = !isExpanded;
-}
-
-interface ShellResult {
- stdout: string;
- stderr: string;
- exitCode: number;
-}
-
-function parseShellResult(result: string): ShellResult | null {
- try {
- const parsed = JSON.parse(result) as unknown;
- if (
- parsed !== null &&
- typeof parsed === "object" &&
- "stdout" in parsed &&
- "stderr" in parsed &&
- "exitCode" in parsed
- ) {
- return {
- stdout: String((parsed as Record<string, unknown>).stdout ?? ""),
- stderr: String((parsed as Record<string, unknown>).stderr ?? ""),
- exitCode: Number((parsed as Record<string, unknown>).exitCode ?? 0),
- };
- }
- return null;
- } catch {
- return null;
- }
-}
-
-const isShell = $derived(toolCall.name === "run_shell");
-const shellResult = $derived(
- isShell && toolCall.result !== undefined ? parseShellResult(toolCall.result) : null,
-);
-
-const summonAgentId = $derived.by(() => {
- if (toolCall.name !== "summon" || !toolCall.result) return null;
- const match = toolCall.result.match(/agent_id:\s*([a-f0-9-]+)/);
- return match ? match[1] : null;
-});
-</script>
-
-<div class="collapse collapse-arrow mb-2 p-1 opacity-60 {isExpanded ? 'collapse-open' : ''}">
- <!-- svelte-ignore a11y_no_static_element_interactions -->
- <div
- class="collapse-title flex items-center gap-2 text-sm italic cursor-pointer w-full text-left"
- onclick={toggle}
- role="button"
- tabindex="0"
- onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') toggle(); }}
- aria-expanded={isExpanded}
- >
- <span class="badge badge-neutral badge-sm">tool</span>
- <span class="font-mono">{toolCall.name}</span>
- {#if summonAgentId !== null}
- <button
- type="button"
- class="btn btn-xs btn-ghost"
- onclick={(e) => { e.stopPropagation(); tabStore.openAgentTab(summonAgentId!); }}
- >Open Tab</button>
- {/if}
- {#if toolCall.result !== undefined}
- {#if toolCall.result.includes("[USER INTERRUPT]")}
- <span class="badge badge-info badge-sm ml-auto">interrupted</span>
- {:else if isShell && shellResult !== null}
- <span class="badge badge-sm ml-auto {shellResult.exitCode === 0 ? 'badge-success' : 'badge-error'}">
- exit {shellResult.exitCode}
- </span>
- {:else if toolCall.isError}
- <span class="badge badge-error badge-sm ml-auto">error</span>
- {:else}
- <span class="badge badge-success badge-sm ml-auto">done</span>
- {/if}
- {:else}
- <span class="badge badge-warning badge-sm ml-auto">pending</span>
- {/if}
- </div>
-
- <div class="collapse-content text-xs">
- <div class="mt-2">
- <p class="font-semibold text-base-content/70 mb-1">Arguments</p>
- <pre class="bg-base-300 rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all">{JSON.stringify(toolCall.arguments, null, 2)}</pre>
- </div>
- {#if isShell && toolCall.result !== undefined}
- {#if shellResult !== null}
- <div class="mt-2">
- <p class="font-semibold text-base-content/70 mb-1">stdout:</p>
- <pre class="bg-base-300 rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all font-mono">{shellResult.stdout || "(empty)"}</pre>
- </div>
- {#if shellResult.stderr}
- <div class="mt-2">
- <p class="font-semibold text-error/80 mb-1">stderr:</p>
- <pre class="bg-error/10 text-error rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all font-mono">{shellResult.stderr}</pre>
- </div>
- {/if}
- <div class="mt-2 flex items-center gap-2">
- <span class="font-semibold text-base-content/70">exit code:</span>
- <span class="badge badge-sm {shellResult.exitCode === 0 ? 'badge-success' : 'badge-error'}">{shellResult.exitCode}</span>
- </div>
- {:else}
- <div class="mt-2">
- <p class="font-semibold text-base-content/70 mb-1">Result</p>
- <pre class="rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all {toolCall.isError ? 'bg-error/20 text-error' : 'bg-base-300'}">{toolCall.result}</pre>
- </div>
- {/if}
- {:else if isShell && toolCall.shellOutput}
- {#if toolCall.shellOutput.stdout}
- <div class="mt-2">
- <p class="font-semibold text-base-content/70 mb-1">stdout</p>
- <pre class="bg-base-300 rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all text-xs">{toolCall.shellOutput.stdout}</pre>
- </div>
- {/if}
- {#if toolCall.shellOutput.stderr}
- <div class="mt-2">
- <p class="font-semibold text-error/70 mb-1">stderr</p>
- <pre class="bg-error/10 rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all text-xs text-error">{toolCall.shellOutput.stderr}</pre>
- </div>
- {/if}
- <span class="text-xs text-base-content/50 italic">Running...</span>
- {:else if toolCall.result !== undefined}
- <div class="mt-2">
- <p class="font-semibold text-base-content/70 mb-1">Result</p>
- <pre
- class="rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all {toolCall.isError
- ? 'bg-error/20 text-error'
- : 'bg-base-300'}">{toolCall.result}</pre>
- </div>
- {/if}
- </div>
-</div>
diff --git a/packages/frontend/src/lib/components/ToolPermissions.svelte b/packages/frontend/src/lib/components/ToolPermissions.svelte
deleted file mode 100644
index 4298724..0000000
--- a/packages/frontend/src/lib/components/ToolPermissions.svelte
+++ /dev/null
@@ -1,185 +0,0 @@
-<script lang="ts">
-import { onMount } from "svelte";
-import { appSettings } from "../settings.svelte.js";
-import type { LogEntry } from "../types.js";
-
-interface ToolPermission {
- id: string;
- label: string;
- description: string;
-}
-
-const toolPermissions: ToolPermission[] = [
- { id: "read", label: "Read files", description: "Allow the AI to read files in the workspace" },
- {
- id: "edit",
- label: "Edit files",
- description: "Allow the AI to write/edit files in the workspace",
- },
- { id: "bash", label: "Run commands", description: "Allow the AI to execute shell commands" },
- {
- id: "summon",
- label: "Summon agents",
- description: "Allow the AI to spawn child agents to work on tasks",
- },
- {
- id: "user_agent",
- label: "Spawn user agents",
- description: "Allow the AI to open new independent top-level tabs",
- },
- {
- id: "send_to_tab",
- label: "Message other tabs",
- description: "Allow the AI to send messages to other tabs by their ID",
- },
- {
- id: "read_tab",
- label: "Read other tabs",
- description: "Allow the AI to read other tabs' latest responses by their ID",
- },
- {
- id: "web_search",
- label: "Web search",
- description: "Allow the AI to search the web via Firecrawl",
- },
- {
- id: "youtube_transcribe",
- label: "YouTube transcripts",
- description: "Allow the AI to fetch YouTube video transcripts",
- },
- {
- id: "search_code",
- label: "Search code",
- description: "Allow the AI to search the codebase with the cs ranked code-search engine",
- },
- {
- id: "key_usage",
- label: "Key usage",
- description:
- "Allow the AI to read current API-key usage levels, rate-limit headroom, and reset times",
- },
- {
- id: "lsp",
- label: "LSP queries",
- description:
- "Allow the AI to query a language server for hover, go-to-definition, references, and more",
- },
-];
-
-const {
- entries = [],
- apiBase = "",
- checkedTools = null,
- onToolToggle = null,
-}: {
- entries?: LogEntry[];
- apiBase?: string;
- /** External checked set (agent builder mode). When null, uses appSettings. */
- checkedTools?: Set<string> | null;
- /** Callback when a tool is toggled in external mode. */
- onToolToggle?: ((id: string, checked: boolean) => void) | null;
-} = $props();
-
-/** Whether we're in external (agent builder) mode */
-const externalMode = $derived(checkedTools !== null && onToolToggle !== null);
-
-function isChecked(id: string): boolean {
- if (externalMode) return checkedTools?.has(id) ?? false;
- return appSettings.toolPerms[id] === true;
-}
-
-async function loadPermissions(): Promise<void> {
- const loaded: Record<string, boolean> = { ...appSettings.toolPerms };
- for (const perm of toolPermissions) {
- try {
- const res = await fetch(`${apiBase}/tabs/settings/perm_${perm.id}`);
- if (res.ok) {
- const data = (await res.json()) as { value: string | null };
- if (data.value !== null) {
- loaded[perm.id] = data.value === "allow";
- }
- }
- } catch {
- // ignore
- }
- }
- appSettings.toolPerms = { ...loaded };
- appSettings.savedToolPerms = { ...loaded };
-}
-
-function togglePermission(id: string): void {
- if (externalMode) {
- onToolToggle?.(id, !checkedTools?.has(id));
- return;
- }
- appSettings.toolPerms = { ...appSettings.toolPerms, [id]: !appSettings.toolPerms[id] };
-}
-
-function resetPermissions(): void {
- appSettings.toolPerms = { ...appSettings.savedToolPerms };
-}
-
-onMount(() => {
- if (!externalMode) {
- loadPermissions();
- }
-});
-</script>
-
-<div class="flex flex-col gap-3">
- <div class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Tool Permissions</div>
- {#if !externalMode}
- <p class="text-xs text-base-content/40">Changes are applied when you send your next message.</p>
- {/if}
-
- <div class="flex flex-col gap-1.5">
- {#each toolPermissions as perm (perm.id)}
- <label class="flex items-start gap-2 cursor-pointer p-1 rounded hover:bg-base-200 transition-colors">
- <input
- type="checkbox"
- class="checkbox checkbox-sm rounded-sm mt-0.5"
- checked={isChecked(perm.id)}
- onchange={() => togglePermission(perm.id)}
- />
- <div class="flex flex-col">
- <span class="text-xs font-medium text-base-content">{perm.label}</span>
- <span class="text-xs text-base-content/40">{perm.description}</span>
- </div>
- </label>
- {/each}
- </div>
-
- {#if !externalMode}
- <button
- class="btn btn-sm btn-ghost w-full"
- disabled={!appSettings.toolPermsDirty}
- onclick={resetPermissions}
- >
- Reset
- </button>
-
- <p class="text-xs text-base-content/40">Warning: changing tool access will reset the AI's prompt cache for active conversations, which may increase usage costs.</p>
-
- <!-- Permission Log -->
- {#if entries.length > 0}
- <div class="collapse collapse-arrow bg-base-200 mt-2">
- <input type="checkbox" />
- <div class="collapse-title text-sm font-medium py-2 min-h-0">
- Log ({entries.length})
- </div>
- <div class="collapse-content text-xs max-h-40 overflow-y-auto">
- {#each entries as entry (entry.id)}
- <div class="flex items-center gap-2 py-1 border-b border-base-300">
- <span class="badge badge-sm {entry.action === 'reject' ? 'badge-error' : 'badge-success'}">
- {entry.action}
- </span>
- <span class="text-base-content/70">{entry.permission}</span>
- <span class="text-base-content/50 ml-auto text-xs">{entry.timestamp}</span>
- </div>
- <p class="text-base-content/60 pl-2 pb-1">{entry.description}</p>
- {/each}
- </div>
- </div>
- {/if}
- {/if}
-</div>
diff --git a/packages/frontend/src/lib/config.ts b/packages/frontend/src/lib/config.ts
deleted file mode 100644
index 0565367..0000000
--- a/packages/frontend/src/lib/config.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-const STORAGE_KEY = "dispatch-api-url";
-
-function getDefaultApiBase(): string {
- if (import.meta.env.VITE_API_URL) return import.meta.env.VITE_API_URL;
- // Derive from current page hostname so it works over Tailscale/LAN
- if (typeof window !== "undefined" && window.location.hostname !== "localhost") {
- return `http://${window.location.hostname}:3000`;
- }
- return "http://localhost:3000";
-}
-
-const DEFAULT_API_BASE = getDefaultApiBase();
-
-function loadApiBase(): string {
- if (typeof localStorage !== "undefined") {
- const saved = localStorage.getItem(STORAGE_KEY);
- if (saved) return saved;
- }
- return DEFAULT_API_BASE;
-}
-
-let _apiBase = loadApiBase();
-
-export const config = {
- get apiBase() {
- return _apiBase;
- },
- get wsUrl() {
- return `${_apiBase.replace(/^http/, "ws")}/ws`;
- },
- get defaultApiBase() {
- return DEFAULT_API_BASE;
- },
- setApiBase(url: string) {
- _apiBase = url;
- if (typeof localStorage !== "undefined") {
- if (url === DEFAULT_API_BASE) {
- localStorage.removeItem(STORAGE_KEY);
- } else {
- localStorage.setItem(STORAGE_KEY, url);
- }
- }
- },
-};
diff --git a/packages/frontend/src/lib/context-window.ts b/packages/frontend/src/lib/context-window.ts
deleted file mode 100644
index c4321f8..0000000
--- a/packages/frontend/src/lib/context-window.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import type { CacheStats } from "./types.js";
-
-/**
- * Context-window occupancy for the current tab/model.
- *
- * `current` is the size of the model's context on the MOST RECENT request —
- * the last turn's full prompt (`inputTokens`, which already includes cached
- * tokens for Anthropic) plus what the model generated that turn
- * (`outputTokens`). This mirrors how opencode derives context fullness from
- * the last assistant message, and reflects what actually occupies the model's
- * window — NOT the session-cumulative totals shown by the Cache Rate view.
- *
- * `max` is the model's maximum context window from models.dev (or `null` when
- * unknown). `percent` is `current / max * 100` clamped to [0, 100] (unrounded;
- * the UI decides the displayed precision), or `null` when
- * `max` is unknown — in which case the UI shows the bare token count with no
- * denominator or progress bar.
- */
-export interface ContextUsage {
- current: number;
- max: number | null;
- percent: number | null;
-}
-
-export function computeContextUsage(
- cacheStats: CacheStats | null | undefined,
- contextLimit: number | null | undefined,
-): ContextUsage {
- const last = cacheStats?.last ?? null;
- const current = last ? last.inputTokens + last.outputTokens : 0;
- const max = typeof contextLimit === "number" && contextLimit > 0 ? contextLimit : null;
- // Precise (unrounded) percentage clamped to [0, 100]; the UI formats the
- // decimal places. Kept unrounded so small contexts against huge windows
- // (e.g. a few thousand tokens vs. 1,000,000) still read non-zero.
- const percent = max ? Math.max(0, Math.min(100, (current / max) * 100)) : null;
- return { current, max, percent };
-}
diff --git a/packages/frontend/src/lib/router.svelte.ts b/packages/frontend/src/lib/router.svelte.ts
deleted file mode 100644
index 8bed880..0000000
--- a/packages/frontend/src/lib/router.svelte.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-type Page = "dashboard" | "agent-builder";
-
-let currentPage = $state<Page>("dashboard");
-
-export const router = {
- get page() {
- return currentPage;
- },
- navigate(page: Page) {
- currentPage = page;
- },
-};
diff --git a/packages/frontend/src/lib/settings.svelte.ts b/packages/frontend/src/lib/settings.svelte.ts
deleted file mode 100644
index 1b93804..0000000
--- a/packages/frontend/src/lib/settings.svelte.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-/** Shared reactive app settings. */
-
-let autoExpandThinking = $state(false);
-let systemPrompt = $state("");
-let savedSystemPrompt = $state("");
-let toolPerms = $state<Record<string, boolean>>({
- read: true,
- edit: false,
- bash: false,
- summon: false,
- user_agent: false,
- send_to_tab: false,
- read_tab: false,
- external_directory: false,
- web_search: false,
- youtube_transcribe: false,
- search_code: false,
- key_usage: false,
- lsp: false,
-});
-let savedToolPerms = $state<Record<string, boolean>>({
- read: true,
- edit: false,
- bash: false,
- summon: false,
- user_agent: false,
- send_to_tab: false,
- read_tab: false,
- external_directory: false,
- web_search: false,
- youtube_transcribe: false,
- search_code: false,
- key_usage: false,
- lsp: false,
-});
-let skillChecks = $state<Record<string, boolean>>({});
-let chunkLimit = $state(100);
-
-export const appSettings = {
- get chunkLimit() {
- return chunkLimit;
- },
- set chunkLimit(v: number) {
- chunkLimit = v;
- },
- get autoExpandThinking() {
- return autoExpandThinking;
- },
- set autoExpandThinking(v: boolean) {
- autoExpandThinking = v;
- },
- get systemPrompt() {
- return systemPrompt;
- },
- set systemPrompt(v: string) {
- systemPrompt = v;
- },
- get savedSystemPrompt() {
- return savedSystemPrompt;
- },
- set savedSystemPrompt(v: string) {
- savedSystemPrompt = v;
- },
- get toolPerms() {
- return toolPerms;
- },
- set toolPerms(v: Record<string, boolean>) {
- toolPerms = v;
- },
- get savedToolPerms() {
- return savedToolPerms;
- },
- set savedToolPerms(v: Record<string, boolean>) {
- savedToolPerms = v;
- },
- get toolPermsDirty() {
- return Object.keys(toolPerms).some((k) => toolPerms[k] !== savedToolPerms[k]);
- },
- get skillChecks() {
- return skillChecks;
- },
- set skillChecks(v: Record<string, boolean>) {
- skillChecks = v;
- },
- get skillChecksDirty() {
- return Object.values(skillChecks).some((v) => v);
- },
-};
diff --git a/packages/frontend/src/lib/sidebar-storage.ts b/packages/frontend/src/lib/sidebar-storage.ts
deleted file mode 100644
index 9d9928f..0000000
--- a/packages/frontend/src/lib/sidebar-storage.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-/**
- * LocalStorage persistence for the sidebar panel layout.
- *
- * The sidebar (`SidebarPanel.svelte`) is a variable-length stack of
- * "slot" components; each slot has a dropdown that picks one of N view
- * components to render (Chat Settings, Tasks, Skills, etc.). The
- * source-of-truth `panels` array in the component records the order
- * and selection of each slot. This module persists that array's
- * `selected` field across browser refreshes/loads.
- *
- * Why localStorage and not the backend `settings` table:
- * - The sidebar layout is a UI preference, not domain state. It
- * matches the precedent set by `dispatch-theme` and
- * `dispatch-api-url`, both of which are localStorage.
- * - Per-device layout is reasonable (a phone may want different
- * panels than a desktop).
- * - No backend round-trip on every layout change.
- *
- * Why only the `selected` strings and not the full `Panel` objects:
- * - The `id` field is a session-ephemeral counter
- * (`SidebarPanel.svelte:61`) that exists only to keep Svelte's
- * `{#each ... (panel.id)}` block keyed across reorders. Restoring
- * ids verbatim would have no benefit, and would collide with the
- * module-scoped `nextId` counter on remount.
- * - The `selected` string is everything we need to reconstruct the
- * visible layout.
- */
-
-const LS_KEY = "dispatch-sidebar-panels";
-
-/**
- * The fallback layout when nothing is stored or the stored value is
- * unusable. Matches the hardcoded initial state at
- * `SidebarPanel.svelte:62` so first-ever load is unchanged: a single
- * "Chat Settings" panel.
- */
-const DEFAULT_LAYOUT: ReadonlyArray<string> = ["Chat Settings"];
-
-/**
- * Read the persisted sidebar layout. Returns an array of
- * `panel.selected` strings in render order (top-to-bottom).
- *
- * Falls back to `DEFAULT_LAYOUT` on any of:
- * - localStorage key absent (first-ever load)
- * - JSON.parse throws (corrupt write from a prior session)
- * - parsed value is not an array (someone hand-edited storage)
- * - parsed array, after filtering non-string entries, is empty
- * (preserves the "minimum one panel" invariant the UI enforces
- * via `{#if idx > 0}` on the remove button)
- *
- * Never throws.
- */
-export function loadSidebarPanels(): string[] {
- try {
- const raw = localStorage.getItem(LS_KEY);
- if (!raw) return [...DEFAULT_LAYOUT];
- const parsed: unknown = JSON.parse(raw);
- if (!Array.isArray(parsed)) return [...DEFAULT_LAYOUT];
- // Discard any non-string entries — they could only come from a
- // hand-edited localStorage or a future schema mismatch. Either
- // way, we don't want to render `undefined` as a panel name.
- const cleaned = parsed.filter((x): x is string => typeof x === "string");
- return cleaned.length > 0 ? cleaned : [...DEFAULT_LAYOUT];
- } catch {
- // localStorage access threw (SecurityError in restricted browser
- // contexts, etc.). Fall through to default.
- return [...DEFAULT_LAYOUT];
- }
-}
-
-/**
- * Persist the current sidebar layout. Best-effort: errors are
- * swallowed (quota exceeded, localStorage disabled in private mode,
- * SecurityError, etc.). The next save attempt may succeed; even if
- * none do, the current session continues to work — only the cross-
- * reload restore is degraded.
- */
-export function saveSidebarPanels(selected: string[]): void {
- try {
- localStorage.setItem(LS_KEY, JSON.stringify(selected));
- } catch {
- // Best-effort.
- }
-}
diff --git a/packages/frontend/src/lib/snapshot-sequencer.ts b/packages/frontend/src/lib/snapshot-sequencer.ts
deleted file mode 100644
index fccc9ef..0000000
--- a/packages/frontend/src/lib/snapshot-sequencer.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-/**
- * Tiny race guard for "the most-recent request wins" semantics.
- *
- * When a frontend component fans out multiple HTTP calls that each return a
- * full snapshot of shared state — and applying an older snapshot would clobber
- * a newer one — wrap each call with `seq = sequencer.begin()` before send and
- * `sequencer.accept(seq)` before applying the response. Older sequences are
- * rejected.
- *
- * Why: the Claude Wake Schedule's POST /toggle and GET /wake-schedule both
- * return the *whole* schedule. If a user toggles hour 9 (request A) and then
- * hour 10 (request B), and B's response arrives before A's, the older A
- * response — which doesn't know about hour 10 yet — would otherwise overwrite
- * hour 10 right out of the UI. A per-hour counter is NOT enough because the
- * race spans different hours (and also covers the initial-load vs first-click
- * race).
- *
- * `>=` on accept is intentional: if seq equals the latest applied seq, the
- * response is a redundant arrival of the most-recent winner — accepting it
- * (idempotently) is fine. The discriminator is *strictly less than*.
- */
-export class SnapshotSequencer {
- private nextSeq = 0;
- private latestApplied = 0;
-
- /** Tag a new request. Call before sending; pass the returned seq to accept(). */
- begin(): number {
- this.nextSeq += 1;
- return this.nextSeq;
- }
-
- /**
- * Decide whether to apply a response. Returns true if this seq is the
- * newest seen so far (and updates the watermark); false if a newer
- * response has already won.
- */
- accept(seq: number): boolean {
- if (seq < this.latestApplied) return false;
- this.latestApplied = seq;
- return true;
- }
-
- /** Inspect (for tests / debugging). */
- get state(): { nextSeq: number; latestApplied: number } {
- return { nextSeq: this.nextSeq, latestApplied: this.latestApplied };
- }
-}
diff --git a/packages/frontend/src/lib/tabs.svelte.ts b/packages/frontend/src/lib/tabs.svelte.ts
deleted file mode 100644
index 16805df..0000000
--- a/packages/frontend/src/lib/tabs.svelte.ts
+++ /dev/null
@@ -1,2441 +0,0 @@
-// Import the chunk-builder helpers directly from core so the frontend store
-// and the backend agent share the exact same wire-format logic. Deep import
-// is intentional: the core barrel pulls in node-only deps (chokidar, etc.)
-// that don't belong in the browser bundle.
-import {
- appendEventToChunks,
- applySystemEvent,
- type IdentifiedMessage,
- type SystemEventLike,
-} from "@dispatch/core/src/chunks/append.js";
-// DB-free; safe in the browser bundle. The flat chunk log is the frontend's
-// source of truth for HISTORY; `groupRowsToMessages` derives render bubbles.
-import { groupRowsToMessages, type MessageRow } from "@dispatch/core/src/chunks/transform.js";
-import type { ChunkRow, UserContentPart } from "@dispatch/core/src/types/index.js";
-import {
- type AgentModelEntry,
- DEFAULT_REASONING_EFFORT,
- isReasoningEffort,
- type ReasoningEffort,
-} from "@dispatch/core/src/types/index.js";
-import { intactTokenIds, type StagedAttachment } from "./attachment-tokens.js";
-import { cacheWarming } from "./cache-warming.svelte.js";
-import { config } from "./config.js";
-import { appSettings } from "./settings.svelte.js";
-import type {
- AgentEvent,
- CacheStats,
- ChatMessage,
- Chunk,
- DebugInfo,
- LogEntry,
- PermissionPrompt,
- QueuedMessage,
- TabStatusSnapshot,
- TaskItem,
-} from "./types.js";
-import { wsClient } from "./ws.svelte.js";
-
-function generateId(): string {
- if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
- return crypto.randomUUID();
- }
- // Fallback for non-secure contexts (HTTP over Tailscale/LAN)
- return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
- const r = (Math.random() * 16) | 0;
- return (c === "x" ? r : (r & 0x3) | 0x8).toString(16);
- });
-}
-
-function makeDebugInfo(overrides: Partial<DebugInfo> = {}): DebugInfo {
- return {
- timestamp: new Date().toISOString(),
- connectionStatus: wsClient.connectionStatus,
- ...overrides,
- };
-}
-
-// ─── Chunk-log → render projection ───────────────────────────────
-//
-// History lives as a flat `ChunkRow[]` (sealed, real seq). For rendering we
-// group it into bubbles with `groupRowsToMessages` (pairs tool_call+tool_result
-// by callId, wraps a turn's assistant chunks) — a pure, ephemeral view, never
-// stored as the source of truth.
-
-/** Map a grouped chunk-row message to a render `ChatMessage`. */
-function rowGroupToMessage(m: MessageRow): ChatMessage {
- return {
- id: m.id,
- role: m.role,
- chunks: m.chunks,
- isStreaming: false,
- seq: m.seq,
- turnId: m.turnId,
- };
-}
-
-/**
- * The render view for a tab: grouped sealed chunks followed by the transient
- * live tail (current unsealed turn). This is what the chat panel renders.
- */
-function deriveRenderGroups(chunks: ChunkRow[], live: ChatMessage[]): ChatMessage[] {
- const sealed = groupRowsToMessages(chunks).map(rowGroupToMessage);
- return live.length > 0 ? [...sealed, ...live] : sealed;
-}
-
-/** Total chunk count of the live tail (for the eviction budget). */
-function countLiveChunks(live: ChatMessage[]): number {
- return live.reduce((sum, m) => sum + m.chunks.length, 0);
-}
-
-/** Smallest `seq` among sealed chunk rows, or null when empty. */
-function minSeqOf(chunks: ChunkRow[]): number | null {
- let min: number | null = null;
- for (const c of chunks) {
- if (typeof c.seq === "number" && (min === null || c.seq < min)) min = c.seq;
- }
- return min;
-}
-
-/** Merge older chunk rows into a window, dedupe by `seq`, keep ascending. */
-function mergeChunksBySeq(existing: ChunkRow[], incoming: ChunkRow[]): ChunkRow[] {
- const bySeq = new Map<number, ChunkRow>();
- for (const c of existing) bySeq.set(c.seq, c);
- for (const c of incoming) bySeq.set(c.seq, c);
- return [...bySeq.values()].sort((a, b) => a.seq - b.seq);
-}
-
-/** Fetch a raw chunk window from the backend (the chunk-native load source). */
-async function fetchChunkWindow(
- tabId: string,
- params: { limit?: number; before?: number } = {},
-): Promise<{ ok: boolean; chunks: ChunkRow[]; total: number; oldestSeq: number | null }> {
- const qs = new URLSearchParams();
- if (params.limit !== undefined) qs.set("limit", String(params.limit));
- if (params.before !== undefined) qs.set("before", String(params.before));
- const q = qs.toString();
- try {
- const res = await fetch(`${config.apiBase}/tabs/${tabId}/chunks${q ? `?${q}` : ""}`);
- if (!res.ok) return { ok: false, chunks: [], total: 0, oldestSeq: null };
- const data = (await res.json()) as {
- chunks?: ChunkRow[];
- total?: number;
- oldestSeq?: number | null;
- };
- const chunks = Array.isArray(data.chunks) ? data.chunks : [];
- return {
- ok: true,
- chunks,
- total: data.total ?? chunks.length,
- oldestSeq: data.oldestSeq ?? minSeqOf(chunks),
- };
- } catch {
- return { ok: false, chunks: [], total: 0, oldestSeq: null };
- }
-}
-
-export interface Tab {
- id: string;
- title: string;
- /**
- * SEALED conversation history as a flat chunk log (real per-tab `seq`).
- * The source of truth for history and the unit of eviction + pagination.
- */
- chunks: ChunkRow[];
- /**
- * Transient render buffer for the CURRENT (unsealed) turn only: the
- * optimistic user message, the in-flight assistant turn (folded from
- * stream deltas), queued/consumed user messages, interrupt splits. Tiny and
- * short-lived — cleared and folded into `chunks` (via refetch) the moment
- * the turn seals. NOT stored history.
- */
- live: ChatMessage[];
- /**
- * Materialized render projection = groupRowsToMessages(chunks) ++ live,
- * recomputed by `updateTab` after any change to `chunks`/`live`. A derived
- * cache for the view layer — NOT the source of truth, never the
- * eviction/pagination unit.
- */
- renderGroups: ChatMessage[];
- /** turn_id of the in-flight turn (stable render keys + reconcile). */
- liveTurnId: string | null;
- agentStatus: "idle" | "running" | "error";
- keyId: string | null;
- modelId: string | null;
- reasoningEffort: ReasoningEffort;
- currentAssistantId: string | null;
- tasks: TaskItem[];
- injectedSkills: string[];
- parentTabId: string | null;
- persistent: boolean;
- agentSlug: string | null;
- agentScope: string | null;
- agentModels: AgentModelEntry[] | null;
- workingDirectory: string | null;
- queuedMessages: QueuedMessage[];
- chunkLimit: number;
- /** Smallest `seq` currently in `chunks` — the backward-pagination cursor. */
- oldestLoadedSeq: number | null;
- /** Total chunk count for this tab on the backend (drives "more to load?"). */
- totalChunks: number;
- /**
- * Unsent chat-input text for THIS tab (in-memory only — never persisted).
- * Saved/restored on tab switch so a draft is never lost or clobbered by
- * switching tabs. Cleared on send.
- */
- draft: string;
- /**
- * Staged image/PDF attachments for THIS tab's unsent draft (in-memory only —
- * never persisted). Each corresponds to an inline `【image:…】`/`【pdf:…】`
- * token in `draft`; removing the token detaches the attachment (reconciled on
- * every keystroke). Ephemeral: sent to the model for one turn, then cleared.
- */
- attachments: StagedAttachment[];
- /**
- * True once the user has manually renamed this tab (double-click rename).
- * Suppresses the first-message auto-title so a chosen name is never
- * clobbered. In-memory only — a renamed tab is no longer "New Tab" on
- * reload, so the auto-title guard already won't fire for it.
- */
- manualTitle: boolean;
- /**
- * Cumulative prompt-cache token telemetry for this tab since the page
- * loaded (in-memory only — resets on reload). Undefined until the first
- * `usage` event arrives. Drives the "Cache Rate" sidebar view.
- */
- cacheStats?: CacheStats;
- /**
- * Compaction UI state. `compactingSource` is set on a TRANSIENT placeholder
- * tab while it hosts the "compacting…" screen, naming the conversation being
- * compacted. `isCompacting` is set on the SOURCE tab while its compaction is
- * in flight (input locked). Both clear when compaction settles.
- */
- compactingSource?: string | null;
- isCompacting?: boolean;
- /** Error message shown on a placeholder tab when compaction fails. */
- compactionError?: string | null;
-}
-
-/**
- * Build a fresh tab store. Exported so tests can construct a real
- * `$state`-backed instance per test — the production singleton is
- * exported below as `tabStore`. The previous test harness duplicated
- * the store logic against POJO arrays, which made the
- * `structuredClone(svelteProxy)` bug undetectable: native `structuredClone`
- * works on plain arrays and throws on Svelte reactive proxies. See the
- * `chat-store.test.ts` rewrite for the proper integration tests that
- * drive the actual reactive code path.
- */
-export function createTabStore() {
- let tabs: Tab[] = $state([]);
- let activeTabId: string | null = $state(null);
- let pendingPermissions: PermissionPrompt[] = $state([]);
- let permissionLog: LogEntry[] = $state([]);
- let configReloaded = $state(false);
- let isConnected = $state(false);
-
- // Track message IDs that were consumed before the POST /chat response arrived.
- // Keyed by queueId — if consumed before we process the response, we skip the queued state.
- const recentlyConsumedIds = new Set<string>();
-
- // Tabs whose UI is currently scrolled up (viewing older history). While a
- // tab is in this set, automatic eviction is suppressed so messages don't
- // vanish out from under the user's viewport. ChatPanel toggles this via
- // `setScrolledUp`. A `force` eviction ignores this set entirely.
- const scrolledUpTabs = new Set<string>();
-
- // tabId → the turn_id whose reconcile was deferred because the user was
- // scrolled up. A Map (not a Set) so the deferred flush knows which turn
- // sealed and can preserve a newer turn that started streaming meanwhile.
- // Flushed when they return to the bottom so we don't yank their viewport.
- const pendingReconcileTabs = new Map<string, string>();
-
- // Clear any stale listeners from HMR reloads, then register
- wsClient.clearCallbacks();
- wsClient.onEvent((event) => {
- handleEvent(event as AgentEvent & { tabId?: string });
- });
-
- // Let the cache-warming store resolve a tab's provider request params
- // (key/model/fallback chain) at fire time, straight from live tab state.
- cacheWarming.setRequestResolver((tabId) => {
- const t = getTabById(tabId);
- if (!t) return null;
- return {
- keyId: t.keyId,
- modelId: t.modelId,
- agentModels: t.agentModels,
- reasoningEffort: t.reasoningEffort,
- };
- });
-
- $effect.root(() => {
- $effect(() => {
- isConnected = wsClient.connectionStatus === "connected";
- });
- });
-
- function getActiveTab(): Tab | undefined {
- return tabs.find((t) => t.id === activeTabId);
- }
-
- function getTabById(id: string): Tab | undefined {
- return tabs.find((t) => t.id === id);
- }
-
- /**
- * Minimum display length of a tab handle (git-style short id). Mirrors
- * `MIN_TAB_PREFIX_LENGTH` in core's `db/tabs.ts` so the handle the user sees
- * is always resolvable by the backend's `resolveTabPrefix`.
- */
- const MIN_HANDLE_LENGTH = 4;
-
- /**
- * Compute the shortest unique prefix (≥ MIN_HANDLE_LENGTH chars) of `tabId`
- * among all currently-open tabs — the displayed "handle" agents use to
- * address each other. Purely DERIVED from the UUIDs already in `tabs`; never
- * stored. Grows by one char only when another open tab shares the prefix, and
- * shrinks back when that sibling closes.
- */
- function shortHandleFor(tabId: string): string {
- const others = tabs.map((t) => t.id).filter((id) => id !== tabId);
- for (let len = MIN_HANDLE_LENGTH; len < tabId.length; len++) {
- const candidate = tabId.slice(0, len);
- if (!others.some((id) => id.startsWith(candidate))) return candidate;
- }
- return tabId;
- }
-
- async function createNewTab(): Promise<Tab> {
- const id = generateId();
- const title = "New Tab";
-
- // Create on backend
- try {
- await fetch(`${config.apiBase}/tabs`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ id, title }),
- });
- } catch {
- // Continue even if backend fails — tab works locally
- }
-
- const tab: Tab = {
- id,
- title,
- chunks: [],
- live: [],
- renderGroups: [],
- liveTurnId: null,
- agentStatus: "idle",
- keyId: null,
- modelId: null,
- reasoningEffort: DEFAULT_REASONING_EFFORT,
- currentAssistantId: null,
- tasks: [],
- injectedSkills: [],
- parentTabId: null,
- persistent: true,
- agentSlug: null,
- agentScope: null,
- agentModels: null,
- workingDirectory: null,
- queuedMessages: [],
- chunkLimit: appSettings.chunkLimit,
- draft: "",
- attachments: [],
- manualTitle: false,
- oldestLoadedSeq: null,
- totalChunks: 0,
- compactingSource: null,
- isCompacting: false,
- compactionError: null,
- };
- tabs = [...tabs, tab];
- activeTabId = id;
- cacheWarming.initTab(id);
-
- // Auto-check default skills then apply default agent (sequential to avoid race)
- void (async () => {
- await autoCheckDefaultSkills();
- await autoSelectDefaultAgent(id);
- })();
-
- return tab;
- }
-
- function switchTab(id: string): void {
- if (tabs.some((t) => t.id === id)) {
- activeTabId = id;
- }
- }
-
- function promoteTab(id: string): void {
- const tab = getTabById(id);
- if (!tab) return;
- updateTab(id, { persistent: true });
- switchTab(id);
- }
-
- async function openAgentTab(agentId: string): Promise<void> {
- const tab = getTabById(agentId);
- if (tab) {
- updateTab(agentId, { persistent: true });
- switchTab(agentId);
- return;
- }
-
- // Tab not found locally — try to fetch from backend
- try {
- const tabRes = await fetch(`${config.apiBase}/tabs/${agentId}`);
- if (!tabRes.ok) return; // 404 or other error — tab doesn't exist
- const tabData = (await tabRes.json()) as {
- id: string;
- title: string;
- keyId?: string | null;
- modelId?: string | null;
- status?: string;
- parentTabId?: string | null;
- };
-
- // Load the tail of the flat chunk log (raw rows — the frontend groups
- // for render and evicts/paginates on the flat list).
- const win = await fetchChunkWindow(agentId, { limit: 100 });
-
- const newTab: Tab = {
- id: agentId,
- title: tabData.title,
- chunks: win.chunks,
- live: [],
- renderGroups: deriveRenderGroups(win.chunks, []),
- liveTurnId: null,
- agentStatus: "idle",
- keyId: tabData.keyId ?? null,
- modelId: tabData.modelId ?? null,
- reasoningEffort: DEFAULT_REASONING_EFFORT,
- currentAssistantId: null,
- tasks: [],
- injectedSkills: [],
- parentTabId: tabData.parentTabId ?? null,
- persistent: true,
- agentSlug: null,
- agentScope: null,
- agentModels: null,
- workingDirectory: null,
- queuedMessages: [],
- chunkLimit: appSettings.chunkLimit,
- draft: "",
- attachments: [],
- manualTitle: false,
- oldestLoadedSeq: win.oldestSeq,
- totalChunks: win.total,
- };
- tabs = [...tabs, newTab];
- activeTabId = agentId;
- cacheWarming.initTab(agentId);
- evictChunks(agentId);
- } catch (err) {
- console.error("openAgentTab failed:", err);
- }
- }
-
- async function closeTab(id: string): Promise<void> {
- const tab = getTabById(id);
- if (!tab) return;
-
- cacheWarming.forgetTab(id);
-
- // Archive on backend (also stops any running agent)
- try {
- await fetch(`${config.apiBase}/tabs/${id}`, { method: "DELETE" });
- } catch {
- // Continue with local removal
- }
-
- tabs = tabs.filter((t) => t.id !== id);
-
- // If we closed the active tab, switch to the last remaining or create a new one
- if (activeTabId === id) {
- if (tabs.length > 0) {
- const fallback = tabs[tabs.length - 1];
- if (fallback && !fallback.persistent) {
- updateTab(fallback.id, { persistent: true });
- }
- activeTabId = fallback?.id ?? null;
- } else {
- await createNewTab();
- }
- }
- }
-
- function updateTab(id: string, patch: Partial<Tab>): void {
- tabs = tabs.map((t) => {
- if (t.id !== id) return t;
- const next = { ...t, ...patch };
- // `renderGroups` is a derived cache: recompute it whenever its inputs
- // (`chunks` / `live`) change so the view layer never reads a stale
- // projection. Callers only ever mutate `chunks`/`live`.
- if ("chunks" in patch || "live" in patch) {
- next.renderGroups = deriveRenderGroups(next.chunks, next.live);
- }
- return next;
- });
- }
-
- /**
- * Rename a tab. Records `manualTitle` so the first-message auto-title never
- * clobbers the user's chosen name, and persists the new title to the DB
- * (fire-and-forget — the optimistic local update is the source of truth for
- * the open session).
- */
- function renameTab(id: string, title: string): void {
- const trimmed = title.trim();
- if (!trimmed) return;
- updateTab(id, { title: trimmed, manualTitle: true });
- fetch(`${config.apiBase}/tabs/${id}`, {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ title: trimmed }),
- }).catch(() => {});
- }
-
- /**
- * Reorder the top-row USER tabs to match `orderedUserTabIds`. Subagent tabs
- * (those with a `parentTabId`) keep their relative order untouched — they
- * live in a separate row and aren't draggable. The new left-to-right user
- * order is persisted via `PATCH /tabs/reorder`, which rewrites each open
- * tab's `position` (fire-and-forget, matching the title-persist style).
- */
- function reorderTabs(orderedUserTabIds: string[]): void {
- const byId = new Map(tabs.map((t) => [t.id, t]));
- const ordered = orderedUserTabIds
- .map((id) => byId.get(id))
- .filter((t): t is Tab => t !== undefined && t.parentTabId === null);
- // Bail if the requested order doesn't cover exactly the current user tabs
- // (stale drag against a since-changed tab set) — never drop tabs.
- const currentUserCount = tabs.filter((t) => t.parentTabId === null).length;
- if (ordered.length !== currentUserCount) return;
- const subagentTabs = tabs.filter((t) => t.parentTabId !== null);
- tabs = [...ordered, ...subagentTabs];
- // Persist the full open-tab order (user tabs first, then subagents) so the
- // backend `position` column matches what the user sees on reload.
- const persistOrder = [...ordered, ...subagentTabs].map((t) => t.id);
- fetch(`${config.apiBase}/tabs/reorder`, {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ ids: persistOrder }),
- }).catch(() => {});
- }
-
- /**
- * Persist the unsent chat-input text for a tab (in-memory only). Saved on
- * every keystroke so switching tabs preserves the draft and restoring the
- * target tab shows its own text. No-op if the tab is gone.
- */
- function setDraft(id: string, text: string): void {
- const tab = getTabById(id);
- if (!tab) return;
- // Detach any staged attachment whose inline token is no longer intact in
- // the new draft text (covers atomic-delete, manual mid-token edits, cut,
- // select-all-delete, etc.). The token in the textarea is the ONLY handle
- // on an attachment, so reconciling here keeps the two in lockstep.
- const intact = intactTokenIds(text);
- const keep = tab.attachments.filter((a) => intact.has(a.id));
- if (keep.length !== tab.attachments.length) {
- updateTab(id, { draft: text, attachments: keep });
- } else {
- updateTab(id, { draft: text });
- }
- }
-
- /**
- * Stage a pasted attachment on a tab. The caller is responsible for also
- * inserting the matching `【image:…】`/`【pdf:…】` token into the draft (the
- * token is what keeps the attachment alive through reconciliation). No-op if
- * the tab is gone.
- */
- function addAttachment(id: string, attachment: StagedAttachment): void {
- const tab = getTabById(id);
- if (!tab) return;
- updateTab(id, { attachments: [...tab.attachments, attachment] });
- }
-
- /**
- * Record whether a tab's chat view is scrolled up (viewing older history).
- * Used to suppress automatic eviction while the user is reading old
- * messages — we don't want to delete what they're currently looking at.
- */
- function setScrolledUp(tabId: string, scrolledUp: boolean): void {
- if (scrolledUp) {
- scrolledUpTabs.add(tabId);
- } else {
- scrolledUpTabs.delete(tabId);
- // Returned to the bottom — run any reconcile we deferred while reading.
- const deferredTurnId = pendingReconcileTabs.get(tabId);
- if (deferredTurnId !== undefined) {
- pendingReconcileTabs.delete(tabId);
- reconcileSealedTurn(tabId, deferredTurnId);
- }
- }
- }
-
- /**
- * Drop up to `n` of the oldest chunks from the live tail (front-to-back
- * across its messages), never removing the chunk currently being streamed
- * (the last chunk of the in-flight assistant message). Emptied messages are
- * dropped. Used only when a single in-flight turn alone exceeds the budget.
- */
- function trimLiveChunks(
- live: ChatMessage[],
- n: number,
- streamingId: string | null,
- ): ChatMessage[] {
- let remaining = n;
- const out = live.map((m) => ({ ...m, chunks: [...m.chunks] }));
- for (const m of out) {
- if (remaining <= 0) break;
- const isStreamingMsg = m.id === streamingId || m.isStreaming === true;
- while (m.chunks.length > 0 && remaining > 0) {
- // Keep the last (open) chunk of the actively streaming message.
- if (isStreamingMsg && m.chunks.length === 1) break;
- m.chunks.shift();
- remaining--;
- }
- }
- return out.filter((m) => m.chunks.length > 0);
- }
-
- /**
- * Bound a tab's in-memory footprint to `chunkLimit` by rolling eviction of
- * the OLDEST chunks. Sealed history (`tab.chunks`) is trimmed from the front
- * first; if a single in-flight turn alone still exceeds the budget, the
- * oldest chunks of the live tail are trimmed too (never the chunk currently
- * being streamed). Evicted sealed chunks are re-fetched on scroll-up via
- * `loadOlderChunks`; live chunks that haven't sealed yet are recovered by
- * the turn-completion reconcile once their write lands. Suppressed while
- * scrolled up unless `force` is set.
- */
- function evictChunks(tabId: string, force = false): void {
- const tab = getTabById(tabId);
- if (!tab) return;
- if (!force && scrolledUpTabs.has(tabId)) return;
-
- const limit = appSettings.chunkLimit;
- if (!Number.isFinite(limit) || limit <= 0) return;
-
- let sealed = tab.chunks;
- let live = tab.live;
- let total = sealed.length + countLiveChunks(live);
- if (total <= limit) return;
-
- // 1. Drop oldest sealed chunk rows from the front.
- if (sealed.length > 0) {
- let dropTo = 0;
- while (total > limit && dropTo < sealed.length) {
- dropTo++;
- total--;
- }
- if (dropTo > 0) sealed = sealed.slice(dropTo);
- }
-
- // 2. Still over budget → one live turn exceeds the limit on its own.
- if (total > limit && live.length > 0) {
- live = trimLiveChunks(live, total - limit, tab.currentAssistantId);
- }
-
- updateTab(tabId, {
- chunks: sealed,
- live,
- oldestLoadedSeq: minSeqOf(sealed) ?? tab.oldestLoadedSeq,
- });
- }
-
- /**
- * Fetch and prepend the next older page of CHUNKS (raw rows). Called when
- * the user scrolls toward the top. Pages backward by the oldest loaded
- * `seq` (`?before=`), dedupes by `seq`, and keeps the window seq-sorted —
- * so a turn split across the window boundary regroups into one bubble with
- * no special-casing. Does NOT evict (the user is reading history).
- */
- async function loadOlderChunks(tabId: string): Promise<void> {
- const tab = getTabById(tabId);
- if (!tab) return;
- const before = tab.oldestLoadedSeq;
- const win = await fetchChunkWindow(tabId, {
- limit: 50,
- ...(before !== null ? { before } : {}),
- });
- const current = getTabById(tabId);
- if (!current) return;
- if (win.chunks.length === 0) {
- // Nothing older; refresh the total if the backend reported a real one.
- if (win.total > 0) updateTab(tabId, { totalChunks: win.total });
- return;
- }
- const merged = mergeChunksBySeq(current.chunks, win.chunks);
- updateTab(tabId, {
- chunks: merged,
- oldestLoadedSeq: minSeqOf(merged),
- totalChunks: win.total,
- });
- }
-
- function ensureAssistantMessage(tabId: string): ChatMessage | null {
- const tab = getTabById(tabId);
- if (!tab) return null;
-
- if (tab.currentAssistantId) {
- const existing = tab.live.find((m) => m.id === tab.currentAssistantId);
- if (existing) return existing;
- }
-
- const id = generateId();
- const newMsg: ChatMessage = {
- id,
- role: "assistant",
- chunks: [],
- isStreaming: true,
- ...(tab.liveTurnId !== null ? { turnId: tab.liveTurnId } : {}),
- };
- updateTab(tabId, {
- currentAssistantId: id,
- live: [...tab.live, newMsg],
- });
- evictChunks(tabId);
- return newMsg;
- }
-
- /**
- * Update the live tail (the current unsealed turn). All streaming handlers
- * operate here; sealed history (`tab.chunks`) is never touched by streaming.
- */
- function updateLive(tabId: string, updater: (live: ChatMessage[]) => ChatMessage[]): void {
- const tab = getTabById(tabId);
- if (!tab) return;
- updateTab(tabId, { live: updater(tab.live) });
- }
-
- /**
- * Apply a content-producing event to the in-flight assistant message via the
- * shared core helper.
- *
- * Reactivity contract: `appendEventToChunks` mutates the chunks array in
- * place, but Svelte 5 `$state` only triggers updates when we reassign at the
- * `tabs` array level. We snapshot the message's chunks via
- * `$state.snapshot` (Svelte's own safe clone — strips reactive proxies and
- * falls back gracefully where native `structuredClone` would throw
- * `DataCloneError` on a `$state` proxy), mutate the snapshot, then write
- * it back through `updateLive`. The previous use of `structuredClone`
- * here threw silently and was swallowed by the WS try/catch — left chunks
- * empty for every streaming turn.
- */
- function applyChunkEvent(tabId: string, event: AgentEvent): void {
- ensureAssistantMessage(tabId);
- const tab = getTabById(tabId);
- if (!tab) return;
- const currentId = tab.currentAssistantId;
- if (!currentId) return;
- updateLive(tabId, (msgs) =>
- msgs.map((m) => {
- if (m.id !== currentId) return m;
- const cloned = $state.snapshot(m.chunks) as Chunk[];
- // The frontend's local AgentEvent is structurally compatible with
- // core's for every variant the helper cares about; the variants
- // where shapes differ (tab-created, done, status, message-*) are
- // all in the helper's no-op branch.
- appendEventToChunks(cloned, event as unknown as Parameters<typeof appendEventToChunks>[1]);
- return { ...m, chunks: cloned, isStreaming: true };
- }),
- );
- // A chunk may have just completed — keep the in-memory footprint bounded.
- evictChunks(tabId);
- }
-
- /**
- * Route a system event when there's no in-flight assistant turn. Wraps
- * `applySystemEvent` from core, which either appends a `system` chunk to
- * the most recent `role: "system"` message or creates a new one.
- */
- function routeSystemEvent(tabId: string, sysEvent: SystemEventLike): void {
- const tab = getTabById(tabId);
- if (!tab) return;
- // Operate on the live tail (applySystemEvent appends a system chunk to
- // the trailing system message or creates one). Build a shallow-cloned
- // IdentifiedMessage[] view via `$state.snapshot` (safe against Svelte 5
- // reactive proxies; native `structuredClone` would throw), run the
- // helper, then write it back. The backend persists this system row too,
- // so it reconciles into `chunks` on the next turn/load.
- const view: IdentifiedMessage[] = tab.live.map((m) => ({
- id: m.id,
- role: m.role,
- chunks: $state.snapshot(m.chunks) as Chunk[],
- }));
- applySystemEvent(view, sysEvent, generateId);
-
- // Reconcile: rebuild the live array from the view, preserving existing
- // message metadata (isStreaming, debugInfo) where IDs match.
- const byId = new Map(tab.live.map((m) => [m.id, m]));
- const rebuilt: ChatMessage[] = view.map((v) => {
- const existing = byId.get(v.id);
- if (existing) {
- return { ...existing, role: v.role, chunks: v.chunks as Chunk[] };
- }
- return {
- id: v.id,
- role: v.role,
- chunks: v.chunks as Chunk[],
- isStreaming: false,
- };
- });
- updateTab(tabId, { live: rebuilt });
- }
-
- /**
- * Reload a tab's chunk window from the API and fold the sealed turn out of
- * the live tail. The persisted chunk log is the source of truth. Two modes:
- * - turn-completion reconcile (`preserveActiveTurn=true`, `sealedTurnId`
- * set): the just-sealed turn's rows now carry real seqs. Drop that turn
- * from `live`, but PRESERVE (a) a newer turn that began streaming while a
- * reconcile was deferred — the queued-message race — and (b) optimistic
- * user messages not yet bound to a turn, so neither is wiped.
- * - WS-reconnect desync (`preserveActiveTurn=false`): the backend has moved
- * on and is idle, so trust the DB fully and clear the live tail.
- * A failed fetch is a no-op (never wipes a populated tab).
- */
- async function reloadChunksFromApi(
- tabId: string,
- preserveActiveTurn = false,
- sealedTurnId?: string,
- ): Promise<void> {
- const win = await fetchChunkWindow(tabId, { limit: 100 });
- if (!win.ok) return;
- const current = getTabById(tabId);
- if (!current) return;
- // A turn that started streaming AFTER the one being reconciled must not be
- // wiped — only the sealed turn folds into `chunks`.
- const preserveTurnId =
- preserveActiveTurn && current.liveTurnId !== null && current.liveTurnId !== sealedTurnId
- ? current.liveTurnId
- : null;
- const keptLive = preserveActiveTurn
- ? current.live.filter(
- (m) =>
- (preserveTurnId !== null && m.turnId === preserveTurnId) ||
- // Optimistic / queued user messages not yet bound to a turn.
- (m.turnId === undefined && m.role === "user"),
- )
- : [];
- const stillActive = preserveTurnId !== null;
- updateTab(tabId, {
- chunks: win.chunks,
- live: keptLive,
- liveTurnId: stillActive ? current.liveTurnId : null,
- currentAssistantId: stillActive ? current.currentAssistantId : null,
- oldestLoadedSeq: win.oldestSeq,
- totalChunks: win.total,
- });
- evictChunks(tabId);
- }
-
- /**
- * Turn-completion reconcile. On `turn-sealed`, fold the just-finished turn
- * (`sealedTurnId`) into the sealed log by reloading the chunk window (real
- * seqs) and dropping that turn from the live tail — while preserving any
- * newer in-flight turn and not-yet-sealed optimistic user messages. Deferred
- * while the user is scrolled up so the viewport isn't disturbed; re-attempted
- * (with the same `sealedTurnId`) when they return to the bottom.
- */
- function reconcileSealedTurn(tabId: string, sealedTurnId: string): void {
- const tab = getTabById(tabId);
- if (!tab) return;
- if (tab.live.length === 0 && tab.liveTurnId === null) return;
- if (scrolledUpTabs.has(tabId)) {
- pendingReconcileTabs.set(tabId, sealedTurnId);
- return;
- }
- pendingReconcileTabs.delete(tabId);
- void reloadChunksFromApi(tabId, true, sealedTurnId);
- }
-
- /**
- * Hydrate the tab store from the backend on app mount. Restores the
- * full list of open tabs (every row with `is_open = 1` in the DB),
- * loads each tab's persisted message history, and seeds the in-flight
- * assistant message for any tab the backend is currently streaming.
- *
- * Wire calls:
- * - GET /tabs → list of open tabs in `position` order
- * - GET /tabs/:id/messages → persisted ChatMessage[] for each
- * - GET /status → in-flight TabStatusSnapshot map
- *
- * Failure modes (all log + continue with whatever was successfully
- * hydrated; callers fall back to creating a fresh tab if the final
- * `tabs` array is empty):
- * - /tabs request fails → no tabs restored
- * - /tabs/:id/messages fails → that tab restored with empty messages
- * - /status fails → tabs restored, in-flight streaming will be
- * lost (will surface as a static "running" status until the next
- * event arrives); harmless because the WS will broadcast `statuses`
- * on reconnect anyway.
- *
- * Returns the number of tabs hydrated (0 on total failure, ≥1 on
- * partial or full success). Caller uses this to decide whether to
- * create a fresh tab.
- *
- * Idempotency: if `tabs.length > 0` when called, returns 0 without
- * touching state — the caller already has tabs from elsewhere (e.g.
- * a hot-reload that preserved Svelte state).
- */
- async function hydrateFromBackend(): Promise<number> {
- if (tabs.length > 0) return 0;
-
- // 1. Fetch the list of open tabs from the DB.
- let tabRows: Array<{
- id: string;
- title: string;
- keyId?: string | null;
- modelId?: string | null;
- parentTabId?: string | null;
- // Backend usage aggregate (GET /tabs). Structurally identical to
- // CacheStats, so it seeds `cacheStats` directly on reload. This is the
- // initial seed (hydrate runs only when tabs.length === 0, i.e. a true
- // reload); thereafter `turn-sealed` REPLACES cacheStats with the same
- // aggregate each turn, keeping the live accumulator reconciled to the DB
- // truth. Neither path ADDS to live events, so there is no double-count.
- usageStats?: CacheStats | null;
- }> = [];
- try {
- const res = await fetch(`${config.apiBase}/tabs`);
- if (!res.ok) return 0;
- const data = (await res.json()) as { tabs?: typeof tabRows };
- tabRows = Array.isArray(data.tabs) ? data.tabs : [];
- } catch {
- return 0;
- }
-
- if (tabRows.length === 0) return 0;
-
- // 2. Fetch the in-flight snapshot. Failure is non-fatal.
- let statusMap: Record<string, TabStatusSnapshot> = {};
- try {
- const res = await fetch(`${config.apiBase}/status`);
- if (res.ok) {
- const data = (await res.json()) as { statuses?: Record<string, TabStatusSnapshot> };
- if (data.statuses && typeof data.statuses === "object") {
- statusMap = data.statuses;
- }
- }
- } catch {
- // Non-fatal: tabs still restore with idle status.
- }
-
- // 3. For each tab, fetch its chunk window (raw rows) in parallel.
- type Win = { ok: boolean; chunks: ChunkRow[]; total: number; oldestSeq: number | null };
- const winByTab = new Map<string, Win>();
- for (const { id, win } of await Promise.all(
- tabRows.map(async (row) => ({
- id: row.id,
- win: await fetchChunkWindow(row.id, { limit: 100 }),
- })),
- )) {
- winByTab.set(id, win);
- }
-
- // 4. Build the Tab objects, seeding the in-flight live turn for running
- // tabs from the status snapshot (the unsealed turn isn't in the DB
- // yet; it reconciles into `chunks` when `turn-sealed` arrives).
- const restored: Tab[] = tabRows.map((row) => {
- const snap = statusMap[row.id];
- const win: Win = winByTab.get(row.id) ?? { ok: true, chunks: [], total: 0, oldestSeq: null };
- const agentStatus: Tab["agentStatus"] = snap?.status ?? "idle";
-
- let currentAssistantId: string | null = null;
- let liveTurnId: string | null = null;
- let live: ChatMessage[] = [];
-
- if (agentStatus === "running" && snap?.currentAssistantId) {
- currentAssistantId = snap.currentAssistantId;
- liveTurnId = snap.currentTurnId ?? null;
- live = [
- {
- id: snap.currentAssistantId,
- role: "assistant",
- chunks: snap.currentChunks ? [...snap.currentChunks] : [],
- isStreaming: true,
- ...(liveTurnId !== null ? { turnId: liveTurnId } : {}),
- },
- ];
- }
-
- return {
- id: row.id,
- title: row.title,
- chunks: win.chunks,
- live,
- renderGroups: deriveRenderGroups(win.chunks, live),
- liveTurnId,
- agentStatus,
- keyId: row.keyId ?? null,
- modelId: row.modelId ?? null,
- reasoningEffort: DEFAULT_REASONING_EFFORT,
- currentAssistantId,
- // Rehydrate the todo list from the backend snapshot so a reload
- // doesn't blank the Tasks panel mid-task.
- tasks: snap?.tasks ?? [],
- injectedSkills: [],
- parentTabId: row.parentTabId ?? null,
- persistent: true,
- agentSlug: null,
- agentScope: null,
- agentModels: null,
- workingDirectory: null,
- queuedMessages: [],
- chunkLimit: appSettings.chunkLimit,
- draft: "",
- attachments: [],
- manualTitle: false,
- oldestLoadedSeq: win.oldestSeq,
- totalChunks: win.total,
- cacheStats: row.usageStats ?? undefined,
- };
- });
-
- tabs = restored;
- // Trim each restored tab down to the chunk limit (user starts at bottom).
- for (const t of restored) {
- evictChunks(t.id);
- // Seed warming from persisted per-tab preference. Arms the 4-minute
- // countdown for idle+enabled tabs; running tabs stay paused until
- // their next `status`/`statuses` reconcile flips them idle.
- cacheWarming.initTab(t.id);
- if (t.agentStatus === "running") cacheWarming.onTurnActive(t.id);
- }
- // Activate the first restored tab (the list is already ordered by
- // `position` from the backend).
- activeTabId = restored[0]?.id ?? null;
- return restored.length;
- }
-
- /**
- * Start a conversation compaction (UI-driven). Creates a TRANSIENT
- * placeholder tab that shows the "compacting…" screen, switches to it, and
- * kicks off the backend compaction of `sourceTabId`. Outcome arrives via the
- * `compaction-*` WS events (see handleEvent). Closing the placeholder tab
- * before completion cancels it (DELETE aborts the in-flight summary).
- */
- async function startCompaction(sourceTabId: string): Promise<void> {
- const source = getTabById(sourceTabId);
- if (!source) return;
- if (source.isCompacting) return;
-
- const tempId = generateId();
- // Create the placeholder tab on the backend (so DELETE-on-close can
- // abort the run) and locally (so we can switch to it and show the UI).
- try {
- await fetch(`${config.apiBase}/tabs`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ id: tempId, title: "Compacting…" }),
- });
- } catch {
- // Continue — the run is driven server-side via the compact endpoint.
- }
-
- const placeholder: Tab = {
- id: tempId,
- title: "Compacting…",
- chunks: [],
- live: [],
- renderGroups: [],
- liveTurnId: null,
- agentStatus: "idle",
- keyId: null,
- modelId: null,
- reasoningEffort: DEFAULT_REASONING_EFFORT,
- currentAssistantId: null,
- tasks: [],
- injectedSkills: [],
- parentTabId: null,
- persistent: true,
- agentSlug: null,
- agentScope: null,
- agentModels: null,
- workingDirectory: null,
- queuedMessages: [],
- chunkLimit: appSettings.chunkLimit,
- draft: "",
- manualTitle: true,
- oldestLoadedSeq: null,
- totalChunks: 0,
- attachments: [],
- compactingSource: sourceTabId,
- isCompacting: false,
- compactionError: null,
- };
- tabs = [...tabs, placeholder];
- activeTabId = tempId;
- updateTab(sourceTabId, { isCompacting: true });
-
- try {
- const res = await fetch(`${config.apiBase}/tabs/${tempId}/compact`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ sourceTabId }),
- });
- if (!res.ok) {
- const msg = `Compaction request failed (HTTP ${res.status}).`;
- updateTab(sourceTabId, { isCompacting: false });
- if (getTabById(tempId)) updateTab(tempId, { compactionError: msg, compactingSource: null });
- }
- } catch {
- const msg = "Could not reach the server to start compaction.";
- updateTab(sourceTabId, { isCompacting: false });
- if (getTabById(tempId)) updateTab(tempId, { compactionError: msg, compactingSource: null });
- }
- }
-
- /**
- * Finish a completed compaction: the canonical conversation now lives on
- * `sourceTabId` (re-seeded with summary + preserved tail), the full prior
- * history was relocated to `backupTabId`. Reload the source tab's chunks,
- * insert the backup tab into the sidebar, switch focus back to the source,
- * and discard the transient placeholder.
- */
- async function finishCompaction(ev: {
- tempTabId: string;
- sourceTabId: string;
- backupTabId: string;
- backupTitle: string;
- }): Promise<void> {
- // Reload the re-seeded source conversation from the backend.
- updateTab(ev.sourceTabId, { isCompacting: false });
- await reloadChunksFromApi(ev.sourceTabId);
-
- // Insert the backup tab (full pre-compaction history) if not present.
- if (!getTabById(ev.backupTabId)) {
- const win = await fetchChunkWindow(ev.backupTabId, { limit: 100 });
- const src = getTabById(ev.sourceTabId);
- const backup: Tab = {
- id: ev.backupTabId,
- title: ev.backupTitle,
- chunks: win.chunks,
- live: [],
- renderGroups: deriveRenderGroups(win.chunks, []),
- liveTurnId: null,
- agentStatus: "idle",
- keyId: src?.keyId ?? null,
- modelId: src?.modelId ?? null,
- reasoningEffort: src?.reasoningEffort ?? DEFAULT_REASONING_EFFORT,
- currentAssistantId: null,
- tasks: [],
- injectedSkills: [],
- parentTabId: null,
- persistent: true,
- agentSlug: src?.agentSlug ?? null,
- agentScope: src?.agentScope ?? null,
- agentModels: src?.agentModels ?? null,
- workingDirectory: src?.workingDirectory ?? null,
- queuedMessages: [],
- chunkLimit: appSettings.chunkLimit,
- draft: "",
- manualTitle: true,
- oldestLoadedSeq: win.oldestSeq,
- totalChunks: win.total,
- attachments: [],
- compactingSource: null,
- isCompacting: false,
- compactionError: null,
- };
- tabs = [...tabs, backup];
- evictChunks(ev.backupTabId);
- }
-
- // Switch focus back to the (compacted) source tab and drop the
- // placeholder. If the placeholder was the active tab, focus moves to
- // the source conversation.
- if (activeTabId === ev.tempTabId) activeTabId = ev.sourceTabId;
- tabs = tabs.filter((t) => t.id !== ev.tempTabId);
- }
-
- function handleEvent(event: AgentEvent & { tabId?: string }): void {
- const tabId = event.tabId;
-
- switch (event.type) {
- case "status": {
- if (!tabId) break;
- updateTab(tabId, { agentStatus: event.status });
- // Cache warming never fires mid-turn: pause it while running, and
- // re-arm the 4-minute countdown once the turn ends (idle/error).
- if (event.status === "running") {
- cacheWarming.onTurnActive(tabId);
- }
- if (event.status === "idle" || event.status === "error") {
- // Stop the streaming cursor immediately; the fold of the live
- // tail into the sealed chunk log happens on `turn-sealed`
- // (after the DB write lands — status fires before it).
- updateLive(tabId, (msgs) =>
- msgs.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)),
- );
- updateTab(tabId, { currentAssistantId: null });
- const tab = getTabById(tabId);
- if (tab && !tab.persistent && tabId !== activeTabId) {
- cacheWarming.removeTab(tabId);
- tabs = tabs.filter((t) => t.id !== tabId);
- } else {
- cacheWarming.onTurnEnded(tabId);
- }
- }
- break;
- }
- case "turn-start": {
- if (!tabId) break;
- const tsTab = getTabById(tabId);
- // Tag the in-flight turn. Also backfill the turn_id onto THIS
- // turn's initiating optimistic user message — it was created on
- // send before the turn_id was known — so it key-matches the sealed
- // user row after reconcile (flicker-free; no remount).
- //
- // A turn-start corresponds to exactly one persisted user row
- // (processMessage → explodeUserText), and a queued message never
- // gets its own turn-start (it is drained into a running turn via
- // message-consumed). So the initiator is the single most-recent
- // NON-queued untagged user row. We must NOT tag pending `queued-`
- // rows: they belong to future turns, and tagging them here would
- // wipe them from the UI when THIS turn seals (reconcile drops live
- // rows bound to the sealed turn).
- const taggedLive = tsTab
- ? (() => {
- const live = [...tsTab.live];
- for (let i = live.length - 1; i >= 0; i--) {
- const m = live[i];
- // Stop at the first non-user row (assistant/system
- // boundary): earlier user rows belong to prior turns.
- if (!m || m.role !== "user") break;
- // Skip past pending queued messages (future turns).
- if (m.id.startsWith("queued-")) continue;
- // Most-recent non-queued user row = this turn's
- // initiator. Tag it once (if untagged), then stop.
- if (m.turnId === undefined) {
- live[i] = { ...m, turnId: event.turnId };
- }
- break;
- }
- return live;
- })()
- : undefined;
- updateTab(tabId, {
- liveTurnId: event.turnId,
- ...(taggedLive ? { live: taggedLive } : {}),
- });
- break;
- }
- case "turn-sealed": {
- if (!tabId) break;
- // The turn's rows are now durable — fold THIS turn out of the live
- // tail into the sealed chunk log (refetch real seqs), preserving any
- // newer in-flight turn. Deferred while scrolled up.
- reconcileSealedTurn(tabId, event.turnId);
- // Reconcile cacheStats to the DB source-of-truth carried on the event.
- // REPLACE (not add): the aggregate already includes every persisted
- // usage row for this tab, so this both lands the just-sealed turn's
- // usage AND self-heals any live overshoot (e.g. a rate-limited
- // fallback attempt streamed usage live but was discarded server-side).
- // `usageStats === undefined` (older backend) leaves cacheStats as-is.
- if (event.usageStats !== undefined) {
- updateTab(tabId, { cacheStats: event.usageStats ?? undefined });
- }
- break;
- }
- case "statuses": {
- // WS (re)connect snapshot. The shape was widened to
- // TabStatusSnapshot (status + optional currentChunks +
- // optional currentAssistantId) so the frontend can seed
- // in-flight assistant messages on browser reopen.
- const backend = event.statuses;
- for (const t of tabs) {
- const snap = backend[t.id];
- const backendStatus = snap?.status ?? "idle";
-
- // Desync case: frontend thought it was streaming, backend
- // has already moved on. The turn is persisted now — reload
- // the chunk window so the final answer shows up.
- if (t.agentStatus === "running" && backendStatus !== "running") {
- void reloadChunksFromApi(t.id);
- }
-
- // Status alignment.
- if (t.agentStatus !== backendStatus) {
- updateTab(t.id, { agentStatus: backendStatus });
- }
-
- // Sync cache warming to the reconciled status: pause it while a
- // tab is (still) running, otherwise (re-)arm the idle countdown.
- if (backendStatus === "running") {
- cacheWarming.onTurnActive(t.id);
- } else {
- cacheWarming.onTurnEnded(t.id);
- }
-
- // Rehydrate the todo list from the snapshot (backend truth)
- // so a reconnect/reload doesn't blank the Tasks panel.
- updateTab(t.id, { tasks: snap?.tasks ?? [] });
-
- if (backendStatus === "running") {
- // Seed the in-flight assistant message from the snapshot.
- // This handles the "browser just reopened mid-stream"
- // path: the DB only has chunks up to the last
- // flushAssistant call, but the snapshot has the live
- // in-memory currentChunks.
- if (snap?.currentAssistantId) {
- const targetId = snap.currentAssistantId;
- updateTab(t.id, {
- currentAssistantId: targetId,
- ...(snap.currentTurnId ? { liveTurnId: snap.currentTurnId } : {}),
- });
- updateLive(t.id, (msgs) => {
- const idx = msgs.findIndex((m) => m.id === targetId);
- if (idx >= 0) {
- return msgs.map((m, i) =>
- i === idx
- ? {
- ...m,
- chunks: snap.currentChunks ? [...snap.currentChunks] : m.chunks,
- isStreaming: true,
- }
- : m,
- );
- }
- return [
- ...msgs,
- {
- id: targetId,
- role: "assistant",
- chunks: snap.currentChunks ? [...snap.currentChunks] : [],
- isStreaming: true,
- ...(snap.currentTurnId ? { turnId: snap.currentTurnId } : {}),
- },
- ];
- });
- }
- } else if (t.currentAssistantId) {
- // Not running: clear streaming flags.
- updateLive(t.id, (msgs) =>
- msgs.map((m) => (m.id === t.currentAssistantId ? { ...m, isStreaming: false } : m)),
- );
- updateTab(t.id, { currentAssistantId: null });
- }
- }
- break;
- }
- case "reasoning-delta":
- case "reasoning-end":
- case "text-delta":
- case "tool-call":
- case "tool-result":
- case "shell-output": {
- if (!tabId) break;
- applyChunkEvent(tabId, event);
- break;
- }
- case "usage": {
- if (!tabId) break;
- const tab = getTabById(tabId);
- if (!tab) break;
- const u = event.usage;
- const prev = tab.cacheStats;
- updateTab(tabId, {
- cacheStats: {
- inputTokens: (prev?.inputTokens ?? 0) + u.inputTokens,
- outputTokens: (prev?.outputTokens ?? 0) + u.outputTokens,
- cacheReadTokens: (prev?.cacheReadTokens ?? 0) + u.cacheReadTokens,
- cacheWriteTokens: (prev?.cacheWriteTokens ?? 0) + u.cacheWriteTokens,
- requests: (prev?.requests ?? 0) + 1,
- last: {
- inputTokens: u.inputTokens,
- outputTokens: u.outputTokens,
- cacheReadTokens: u.cacheReadTokens,
- cacheWriteTokens: u.cacheWriteTokens,
- },
- },
- });
- break;
- }
- case "done": {
- if (!tabId) break;
- const tab5 = getTabById(tabId);
- if (!tab5) break;
- updateLive(tabId, (msgs) =>
- msgs.map((m) => (m.id === tab5.currentAssistantId ? { ...m, isStreaming: false } : m)),
- );
- updateTab(tabId, { currentAssistantId: null });
- break;
- }
- case "error": {
- if (!tabId) break;
- const errTab = getTabById(tabId);
- if (!errTab) break;
- if (errTab.currentAssistantId) {
- // In-flight turn: append the error as a chunk on the
- // assistant message via the shared helper. Mark debug info
- // on the message for parity with the previous behavior.
- applyChunkEvent(tabId, event);
- updateLive(tabId, (msgs) =>
- msgs.map((m) =>
- m.id === errTab.currentAssistantId
- ? {
- ...m,
- isStreaming: false,
- debugInfo: makeDebugInfo({ error: event.error }),
- }
- : m,
- ),
- );
- } else {
- // No turn in flight: open a new assistant message holding
- // only the error chunk. We do this by ensuring an assistant
- // message then funneling through applyChunkEvent, which
- // guarantees the chunk shape matches the helper's output.
- ensureAssistantMessage(tabId);
- applyChunkEvent(tabId, event);
- const afterTab = getTabById(tabId);
- if (afterTab?.currentAssistantId) {
- const newId = afterTab.currentAssistantId;
- updateLive(tabId, (msgs) =>
- msgs.map((m) =>
- m.id === newId
- ? {
- ...m,
- isStreaming: false,
- debugInfo: makeDebugInfo({ error: event.error }),
- }
- : m,
- ),
- );
- }
- }
- updateTab(tabId, { currentAssistantId: null, agentStatus: "error" });
- break;
- }
- case "notice": {
- if (!tabId) break;
- const noticeTab = getTabById(tabId);
- if (!noticeTab) break;
- if (noticeTab.currentAssistantId) {
- applyChunkEvent(tabId, event);
- } else {
- routeSystemEvent(tabId, { kind: "notice", text: event.message });
- }
- break;
- }
- case "model-changed": {
- if (!tabId) break;
- const mcTab2 = getTabById(tabId);
- if (!mcTab2) break;
- // Always update the tab's active key/model. Additionally emit
- // a `system` chunk to record the switch at its temporal
- // position (in the assistant turn if one is in flight; else
- // in a standalone system message).
- updateTab(tabId, { keyId: event.keyId, modelId: event.modelId });
- if (mcTab2.currentAssistantId) {
- applyChunkEvent(tabId, event);
- } else {
- routeSystemEvent(tabId, {
- kind: "model-changed",
- text: `Switched to ${event.modelId} (${event.keyId})`,
- });
- }
- break;
- }
- case "permission-prompt": {
- pendingPermissions = event.pending;
- break;
- }
- case "task-list-update": {
- if (tabId) {
- updateTab(tabId, { tasks: event.tasks });
- }
- break;
- }
- case "config-reload": {
- configReloaded = true;
- setTimeout(() => {
- configReloaded = false;
- }, 2500);
- // If a tab + turn is in flight, also record the reload as a
- // system chunk for honest history. If no turn is in flight we
- // could route to a system message, but config-reload is a
- // global signal not scoped to any tab — only the active tab,
- // if any, gets the chunk.
- if (tabId) {
- const crTab = getTabById(tabId);
- if (crTab?.currentAssistantId) {
- applyChunkEvent(tabId, event);
- }
- }
- break;
- }
- case "tab-created": {
- const newTabEvent = event as AgentEvent & {
- id: string;
- title: string;
- keyId: string | null;
- modelId: string | null;
- parentTabId: string | null;
- agentSlug?: string | null;
- workingDirectory: string | null;
- agentModels?: AgentModelEntry[] | null;
- };
- // Only add if we don't already have this tab
- if (!getTabById(newTabEvent.id)) {
- const tab: Tab = {
- id: newTabEvent.id,
- title: newTabEvent.title,
- chunks: [],
- live: [],
- renderGroups: [],
- liveTurnId: null,
- agentStatus: "running",
- keyId: newTabEvent.keyId ?? null,
- modelId: newTabEvent.modelId ?? null,
- reasoningEffort: DEFAULT_REASONING_EFFORT,
- currentAssistantId: null,
- tasks: [],
- injectedSkills: [],
- parentTabId: newTabEvent.parentTabId ?? null,
- persistent: newTabEvent.parentTabId == null,
- agentSlug: newTabEvent.agentSlug ?? null,
- agentScope: null,
- agentModels: newTabEvent.agentModels ?? null,
- workingDirectory: newTabEvent.workingDirectory ?? null,
- queuedMessages: [],
- chunkLimit: appSettings.chunkLimit,
- draft: "",
- attachments: [],
- manualTitle: false,
- oldestLoadedSeq: null,
- totalChunks: 0,
- };
- tabs = [...tabs, tab];
- }
- break;
- }
- case "message-queued": {
- if (!tabId) break;
- const mqEvent = event as AgentEvent & { tabId: string; messageId: string; message: string };
- const mqTab = getTabById(tabId);
- if (!mqTab) break;
- // Only add to queuedMessages if not already tracked (might have been added
- // optimistically by sendMessage using the same queueId)
- const alreadyQueued = mqTab.queuedMessages.some((m) => m.id === mqEvent.messageId);
- if (!alreadyQueued) {
- // Message came from another client/session — add it fresh
- const qm: QueuedMessage = {
- id: mqEvent.messageId,
- message: mqEvent.message,
- timestamp: Date.now(),
- };
- updateTab(tabId, { queuedMessages: [...mqTab.queuedMessages, qm] });
- // Also add as a user message in the live tail if not present.
- const tabAfterQm = getTabById(tabId);
- const existingMsg = tabAfterQm?.live.find(
- (m) => m.id === `queued-${mqEvent.messageId}` || m.id === mqEvent.messageId,
- );
- if (!existingMsg) {
- const userMsg: ChatMessage = {
- id: `queued-${mqEvent.messageId}`,
- role: "user",
- chunks: [{ type: "text", text: mqEvent.message }],
- };
- updateTab(tabId, { live: [...(tabAfterQm?.live ?? []), userMsg] });
- }
- }
- // If alreadyQueued, the optimistic update already put everything in place with the
- // correct `queued-${messageId}` id — nothing more to do.
- break;
- }
- case "message-consumed": {
- if (!tabId) break;
- const mcEvent = event as AgentEvent & {
- tabId: string;
- messageIds: string[];
- reason?: "interrupt" | "continuation";
- };
- const mcTab = getTabById(tabId);
- if (!mcTab) break;
- // Track recently consumed IDs so sendMessage can detect early consumption
- for (const id of mcEvent.messageIds) {
- recentlyConsumedIds.add(id);
- setTimeout(() => recentlyConsumedIds.delete(id), 10000);
- }
- updateTab(tabId, {
- queuedMessages: mcTab.queuedMessages.filter((m) => !mcEvent.messageIds.includes(m.id)),
- });
-
- // "continuation" — these queued messages are draining BETWEEN turns
- // to START a fresh turn (the "queue consumed after turn ends" path),
- // not folding into a running turn's tool result. The backend joins
- // them into ONE initiating user row, so we collapse the matching
- // optimistic `queued-` bubbles into a single UNTAGGED user row. It
- // stays untagged on purpose: the imminent `turn-start` tags it as
- // this new turn's initiator (exactly like a normal send), and
- // reconcile then folds it into the sealed turn. Leaving N separate
- // untagged rows would strand all but the most-recent one (turn-start
- // only tags one), so collapsing is required.
- if (mcEvent.reason === "continuation") {
- updateLive(tabId, (msgs) => {
- const consumedTexts: string[] = [];
- const rest: ChatMessage[] = [];
- let firstConsumedIdx = -1;
- for (const m of msgs) {
- if (m.role === "user" && m.id.startsWith("queued-")) {
- const queuedId = m.id.slice(7);
- if (mcEvent.messageIds.includes(queuedId)) {
- if (firstConsumedIdx === -1) firstConsumedIdx = rest.length;
- const textChunk = m.chunks.find((c) => c.type === "text");
- consumedTexts.push(textChunk && textChunk.type === "text" ? textChunk.text : "");
- continue;
- }
- }
- rest.push(m);
- }
- if (consumedTexts.length === 0) return msgs;
- const initiator: ChatMessage = {
- id: generateId(),
- role: "user",
- chunks: [{ type: "text", text: consumedTexts.join("\n---\n") }],
- };
- rest.splice(firstConsumedIdx === -1 ? rest.length : firstConsumedIdx, 0, initiator);
- return rest;
- });
- break;
- }
-
- // Split the current assistant message: finalize it, then insert
- // the consumed user messages after it. Subsequent streaming events
- // will create a NEW assistant message block below.
- const currentAssistantId = mcTab.currentAssistantId;
- updateLive(tabId, (msgs) => {
- // Extract consumed messages
- const consumed: ChatMessage[] = [];
- const rest: ChatMessage[] = [];
- for (const m of msgs) {
- if (m.id.startsWith("queued-")) {
- const queuedId = m.id.slice(7);
- if (mcEvent.messageIds.includes(queuedId)) {
- // Bind the consumed message to the in-flight turn that is
- // consuming it. Stripping the `queued-` prefix alone leaves
- // it an UNTAGGED user row, which reconcileSealedTurn KEEPS —
- // so the interrupt bubble would linger in the live tail
- // forever AND duplicate the `[USER INTERRUPT]` text the
- // backend folds into the sealed tool-result chunk. Tagging
- // it lets reconcile drop it on seal, collapsing to the
- // persisted shape. (liveTurnId is set for the duration of a
- // running turn, which is the only time a consume happens.)
- consumed.push({
- ...m,
- id: queuedId,
- ...(mcTab.liveTurnId !== null ? { turnId: mcTab.liveTurnId } : {}),
- });
- continue;
- }
- }
- rest.push(m);
- }
- if (consumed.length === 0) return msgs;
-
- // Mark the current assistant message as done streaming
- const result = rest.map((m) =>
- m.id === currentAssistantId ? { ...m, isStreaming: false } : m,
- );
-
- // Insert consumed messages right after the current assistant message
- let insertIdx = result.length;
- for (let i = result.length - 1; i >= 0; i--) {
- if (result[i]?.id === currentAssistantId) {
- insertIdx = i + 1;
- break;
- }
- }
- result.splice(insertIdx, 0, ...consumed);
- return result;
- });
- // Clear currentAssistantId so the next streaming event creates
- // a new assistant message block after the user's message
- updateTab(tabId, { currentAssistantId: null });
- break;
- }
- case "message-cancelled": {
- if (!tabId) break;
- const cancelEvent = event as AgentEvent & { tabId: string; messageId: string };
- const cancelTab = getTabById(tabId);
- if (!cancelTab) break;
- updateTab(tabId, {
- queuedMessages: cancelTab.queuedMessages.filter((m) => m.id !== cancelEvent.messageId),
- live: cancelTab.live.filter(
- (m) => !(m.role === "user" && m.id === `queued-${cancelEvent.messageId}`),
- ),
- });
- break;
- }
- case "compaction-started": {
- const ev = event as AgentEvent & { tempTabId: string; sourceTabId: string };
- // Lock the source tab's input while compaction runs.
- if (getTabById(ev.sourceTabId)) {
- updateTab(ev.sourceTabId, { isCompacting: true });
- }
- // Mark the placeholder tab so it shows the "compacting…" screen.
- if (getTabById(ev.tempTabId)) {
- updateTab(ev.tempTabId, { compactingSource: ev.sourceTabId });
- }
- break;
- }
- case "compaction-complete": {
- const ev = event as AgentEvent & {
- tempTabId: string;
- sourceTabId: string;
- backupTabId: string;
- backupTitle: string;
- };
- void finishCompaction(ev);
- break;
- }
- case "compaction-error": {
- const ev = event as AgentEvent & {
- tempTabId: string;
- sourceTabId: string;
- error: string;
- };
- if (getTabById(ev.sourceTabId)) {
- updateTab(ev.sourceTabId, { isCompacting: false });
- }
- // Surface the error on the placeholder tab (if still open) so the
- // user sees why it failed; the source conversation is untouched.
- const tmp = getTabById(ev.tempTabId);
- if (tmp) {
- updateTab(ev.tempTabId, {
- compactingSource: null,
- compactionError: ev.error,
- });
- }
- break;
- }
- }
- }
-
- async function autoCheckDefaultSkills(): Promise<void> {
- try {
- const res = await fetch(`${config.apiBase}/skills`);
- if (!res.ok) return;
- const data = (await res.json()) as {
- skills?: Array<{
- name: string;
- scope: string;
- directory: string;
- }>;
- };
- const defaultSkills = (data.skills ?? []).filter((s) => s.directory === "default");
- if (defaultSkills.length === 0) return;
- const checks: Record<string, boolean> = { ...appSettings.skillChecks };
- for (const skill of defaultSkills) {
- checks[`${skill.scope}:${skill.name}`] = true;
- }
- appSettings.skillChecks = checks;
- } catch {
- // Silently ignore — skills will still be available for manual checking
- }
- }
-
- async function autoSelectDefaultAgent(tabId: string): Promise<void> {
- try {
- const res = await fetch(`${config.apiBase}/agents`);
- if (!res.ok) return;
- const data = (await res.json()) as {
- agents?: Array<{
- slug: string;
- scope: string;
- name: string;
- skills: string[];
- tools: string[];
- models: AgentModelEntry[];
- cwd?: string;
- }>;
- };
- const agents = data.agents ?? [];
- const defaultAgent = agents.find(
- (a: { slug: string; scope: string }) => a.slug === "default" && a.scope === "global",
- );
- if (!defaultAgent) return;
-
- const tab = getTabById(tabId);
- if (!tab) return;
-
- // Apply the default agent
- const firstModel = defaultAgent.models[0];
- const patch: Partial<Tab> = {
- agentSlug: defaultAgent.slug,
- agentScope: defaultAgent.scope,
- agentModels: defaultAgent.models,
- workingDirectory: defaultAgent.cwd || null,
- };
- if (firstModel) {
- patch.keyId = firstModel.key_id;
- patch.modelId = firstModel.model_id;
- }
- updateTab(tabId, patch);
-
- // Merge the agent's skills into existing checked skills
- if (defaultAgent.skills.length > 0) {
- const checks: Record<string, boolean> = { ...appSettings.skillChecks };
- for (const skillKey of defaultAgent.skills) {
- checks[skillKey] = true;
- }
- appSettings.skillChecks = checks;
- }
-
- // Apply tool permissions
- const perms: Record<string, boolean> = {};
- for (const key of Object.keys(appSettings.toolPerms)) {
- perms[key] = false;
- }
- for (const tool of defaultAgent.tools) {
- perms[tool] = true;
- }
- appSettings.toolPerms = perms;
-
- // Persist to backend
- if (firstModel) {
- fetch(`${config.apiBase}/tabs/${tabId}`, {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ keyId: firstModel.key_id, modelId: firstModel.model_id }),
- }).catch(() => {});
- }
- } catch {
- // Silently ignore
- }
- }
-
- async function refreshAgentConfig(tabId: string): Promise<void> {
- const tab = getTabById(tabId);
- if (!tab?.agentSlug || !tab.agentScope) return;
- try {
- const res = await fetch(`${config.apiBase}/agents`);
- if (!res.ok) return;
- const data = (await res.json()) as {
- agents?: Array<{
- slug: string;
- scope: string;
- models: AgentModelEntry[];
- cwd?: string;
- }>;
- };
- const agents = data.agents ?? [];
- const freshAgent = agents.find((a) => a.slug === tab.agentSlug && a.scope === tab.agentScope);
- if (!freshAgent) return;
- const patch: Partial<Tab> = {
- agentModels: freshAgent.models,
- // NOTE: do not reset workingDirectory here. It is a per-tab user
- // setting (see setWorkingDirectory); refreshing agent config on
- // send must not clobber the directory the user chose for this tab.
- };
- // Preserve the user's current selection if it still exists in the
- // refreshed models list. Only fall back to the first model when the
- // current selection is no longer valid.
- const currentStillValid = freshAgent.models.some(
- (m) => m.key_id === tab.keyId && m.model_id === tab.modelId,
- );
- if (!currentStillValid) {
- const firstModel = freshAgent.models[0];
- if (firstModel) {
- patch.keyId = firstModel.key_id;
- patch.modelId = firstModel.model_id;
- } else {
- patch.keyId = null;
- patch.modelId = null;
- }
- }
- updateTab(tabId, patch);
- } catch {
- // Silently fall back to stale values
- }
- }
-
- async function fetchSkillContent(scope: string, name: string): Promise<string | null> {
- try {
- const res = await fetch(
- `${config.apiBase}/skills/${encodeURIComponent(name)}?scope=${scope}`,
- );
- if (!res.ok) return null;
- const data = (await res.json()) as { content?: string };
- return data.content ?? null;
- } catch {
- return null;
- }
- }
-
- async function sendMessage(text: string, content?: UserContentPart[]): Promise<void> {
- let tab = getActiveTab();
- if (!tab) return;
-
- // A real user message disables+resets the warming timer immediately, so
- // the genuine turn appends to the real history with NO throwaway turns
- // present (it lands on the warm cache). Warming re-arms when this turn
- // ends (see the `status` handler → cacheWarming.onTurnEnded).
- cacheWarming.onUserMessage(tab.id);
-
- // Refresh agent config to pick up any changes made in AgentBuilder
- if (tab.agentSlug && tab.agentScope) {
- await refreshAgentConfig(tab.id);
- tab = getActiveTab();
- if (!tab) return;
- }
-
- // Fetch content for checked skills and build the message to send.
- // `skillPrefix` (when non-empty) is prepended to BOTH the text projection
- // that gets persisted/rendered AND the multimodal content array, so an
- // image turn still carries the activated skills to the model.
- let skillPrefix = "";
- const checkedKeys = Object.entries(appSettings.skillChecks)
- .filter(([, v]) => v)
- .map(([k]) => k);
-
- if (checkedKeys.length > 0) {
- const skillSections: string[] = [];
- for (const key of checkedKeys) {
- const [scope, ...nameParts] = key.split(":");
- const name = nameParts.join(":");
- if (!scope || !name) continue;
- const skillContent = await fetchSkillContent(scope, name);
- if (skillContent) {
- skillSections.push(`<skill name="${name}">\n${skillContent}\n</skill>`);
- }
- }
- if (skillSections.length > 0) {
- skillPrefix = `[The following skills have been activated for this message]\n\n${skillSections.join("\n\n")}\n\n---\n\n`;
- }
-
- // Track injected skills on the tab
- const newInjected = [...new Set([...tab.injectedSkills, ...checkedKeys])];
- updateTab(tab.id, { injectedSkills: newInjected });
-
- // Clear all checks
- appSettings.skillChecks = {};
- }
-
- const messageToSend = `${skillPrefix}${text}`;
- // Prepend the skill prefix to the multimodal content as a leading text
- // part so the model sees the activated skills before the attachments.
- const contentToSend =
- content && skillPrefix ? [{ type: "text" as const, text: skillPrefix }, ...content] : content;
-
- const userMsg: ChatMessage = {
- id: generateId(),
- role: "user",
- chunks: [{ type: "text", text }],
- };
-
- // If the agent is currently running, we expect the POST to be queued.
- // Optimistically assign the queued- prefix and add to queuedMessages BEFORE
- // the POST so that the WS "message-queued" event (which may arrive before
- // the HTTP response) can match the existing chat message instead of creating
- // a duplicate.
- const isRunning = tab.agentStatus === "running";
- let queueId: string | null = null;
- if (isRunning) {
- queueId = generateId();
- userMsg.id = `queued-${queueId}`;
- // Pre-populate queuedMessages so WS event finds it immediately
- tab.queuedMessages = [
- ...tab.queuedMessages,
- { id: queueId, message: text, timestamp: Date.now() },
- ];
- }
-
- // Optimistically show the user's message in the live tail.
- updateTab(tab.id, { live: [...tab.live, userMsg] });
- // Generate a title from the first user message of an empty tab.
- const isFirstMessage = tab.chunks.length === 0 && tab.live.length === 0;
- if (!tab.manualTitle && (isFirstMessage || tab.title === "New Tab")) {
- const titleText = text.length > 50 ? `${text.slice(0, 47)}...` : text;
- updateTab(tab.id, { title: titleText });
- fetch(`${config.apiBase}/tabs/${tab.id}`, {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ title: titleText }),
- }).catch(() => {});
- }
-
- // Save settings to DB before sending (bakes in on send)
- const settingsSaves: Promise<unknown>[] = [];
-
- if (appSettings.systemPrompt !== appSettings.savedSystemPrompt) {
- appSettings.savedSystemPrompt = appSettings.systemPrompt;
- settingsSaves.push(
- fetch(`${config.apiBase}/tabs/settings/system_prompt`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ value: appSettings.systemPrompt }),
- }).catch(() => {}),
- );
- }
-
- if (appSettings.toolPermsDirty) {
- const perms = appSettings.toolPerms;
- appSettings.savedToolPerms = { ...perms };
- for (const [id, enabled] of Object.entries(perms)) {
- settingsSaves.push(
- fetch(`${config.apiBase}/tabs/settings/perm_${id}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ value: enabled ? "allow" : "ask" }),
- }).catch(() => {}),
- );
- }
- }
-
- if (settingsSaves.length > 0) {
- await Promise.all(settingsSaves);
- }
-
- try {
- const res = await fetch(`${config.apiBase}/chat`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- tabId: tab.id,
- message: messageToSend,
- ...(contentToSend ? { content: contentToSend } : {}),
- ...(tab.keyId ? { keyId: tab.keyId } : {}),
- ...(tab.modelId ? { modelId: tab.modelId } : {}),
- ...(tab.agentModels ? { agentModels: tab.agentModels } : {}),
- reasoningEffort: tab.reasoningEffort,
- ...(tab.workingDirectory ? { workingDirectory: tab.workingDirectory } : {}),
- ...(queueId ? { queueId } : {}),
- }),
- });
- if (!res.ok) {
- const body = await res.text();
- // Rollback optimistic queued state on error
- if (queueId) {
- const currentTab = getTabById(tab.id);
- if (currentTab) {
- updateTab(tab.id, {
- queuedMessages: currentTab.queuedMessages.filter((m) => m.id !== queueId),
- });
- }
- updateLive(tab.id, (msgs) =>
- msgs.map((m) => (m.id === `queued-${queueId}` ? { ...m, id: generateId() } : m)),
- );
- }
- const errMsg: ChatMessage = {
- id: generateId(),
- role: "assistant",
- chunks: [
- {
- type: "error",
- message: `Failed to send message (HTTP ${res.status})`,
- statusCode: res.status,
- },
- ],
- isStreaming: false,
- debugInfo: makeDebugInfo({
- error: `HTTP ${res.status}`,
- httpStatus: res.status,
- httpBody: body,
- }),
- };
- updateTab(tab.id, { live: [...(getTabById(tab.id)?.live ?? []), errMsg] });
- } else {
- const responseData = (await res.json()) as { status: string; messageId?: string };
- if (responseData.status === "queued" && responseData.messageId) {
- // The backend confirmed the message was queued with the ID we sent (queueId).
- // If the message was already consumed before we got here (super-fast agent),
- // clean up the optimistic queued state.
- if (queueId && recentlyConsumedIds.has(queueId)) {
- recentlyConsumedIds.delete(queueId);
- // queuedMessages and the queued- prefix have already been cleaned up by
- // the message-consumed handler. Nothing more to do.
- }
- // Otherwise everything is already in place from the optimistic update above.
- } else if (responseData.status === "ok") {
- // Agent wasn't running after all — undo the optimistic queued state if we set it.
- if (queueId) {
- const currentTab = getTabById(tab.id);
- if (currentTab) {
- updateTab(tab.id, {
- queuedMessages: currentTab.queuedMessages.filter((m) => m.id !== queueId),
- });
- }
- // Restore the message to a normal (non-queued) ID
- updateLive(tab.id, (msgs) =>
- msgs.map((m) => (m.id === `queued-${queueId}` ? { ...m, id: generateId() } : m)),
- );
- }
- }
- }
- } catch (err) {
- // Rollback optimistic queued state on network error
- if (queueId) {
- const currentTab = getTabById(tab.id);
- if (currentTab) {
- updateTab(tab.id, {
- queuedMessages: currentTab.queuedMessages.filter((m) => m.id !== queueId),
- });
- }
- updateLive(tab.id, (msgs) =>
- msgs.map((m) => (m.id === `queued-${queueId}` ? { ...m, id: generateId() } : m)),
- );
- }
- const errMsg: ChatMessage = {
- id: generateId(),
- role: "assistant",
- chunks: [{ type: "error", message: "Could not reach the server" }],
- isStreaming: false,
- debugInfo: makeDebugInfo({ error: err instanceof Error ? err.message : String(err) }),
- };
- updateTab(tab.id, { live: [...(getTabById(tab.id)?.live ?? []), errMsg] });
- }
- }
-
- function changeModel(keyId: string, modelId: string): void {
- const tab = getActiveTab();
- if (!tab) return;
- updateTab(tab.id, { keyId, modelId });
-
- // Persist to backend
- fetch(`${config.apiBase}/tabs/${tab.id}`, {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ keyId, modelId }),
- }).catch(() => {});
- }
-
- function setKey(keyId: string): void {
- const tab = getActiveTab();
- if (!tab) return;
- updateTab(tab.id, { keyId, modelId: null });
-
- // Persist to backend
- fetch(`${config.apiBase}/tabs/${tab.id}`, {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ keyId, modelId: null }),
- }).catch(() => {});
- }
-
- /**
- * Update the per-tab reasoning-effort selector. Ignores unrecognised
- * values so an out-of-range string from the UI can't corrupt the tab
- * state. This is the per-tab effort in the per-model → per-tab → default
- * resolution chain.
- */
- function setReasoningEffort(effort: string): void {
- if (!isReasoningEffort(effort)) return;
- const tab = getActiveTab();
- if (!tab) return;
- updateTab(tab.id, { reasoningEffort: effort });
- }
-
- function setWorkingDirectory(dir: string | null): void {
- const tab = getActiveTab();
- if (!tab) return;
- updateTab(tab.id, { workingDirectory: dir || null });
- }
-
- /**
- * Enable/disable prompt-cache warming for a tab (persisted per-tab). The
- * warming store arms or cancels its 4-minute idle timer accordingly.
- */
- function setCacheWarmingEnabled(tabId: string, enabled: boolean): void {
- cacheWarming.setEnabled(tabId, enabled);
- }
-
- function setAgent(
- agent: {
- slug: string;
- scope: string;
- skills: string[];
- tools: string[];
- models: AgentModelEntry[];
- cwd?: string;
- } | null,
- ): void {
- const tab = getActiveTab();
- if (!tab) return;
-
- if (!agent) {
- // Switch back to manual mode — clear agent and reset working directory
- updateTab(tab.id, {
- agentSlug: null,
- agentScope: null,
- agentModels: null,
- workingDirectory: null,
- });
- return;
- }
-
- // Apply agent's first model as the active key+model
- const firstModel = agent.models[0];
- const patch: Partial<Tab> = {
- agentSlug: agent.slug,
- agentScope: agent.scope,
- agentModels: agent.models,
- workingDirectory: agent.cwd || null,
- };
- if (firstModel) {
- patch.keyId = firstModel.key_id;
- patch.modelId = firstModel.model_id;
- }
- updateTab(tab.id, patch);
-
- // Reset and apply the agent's skills (don't accumulate from previous agents)
- const checks: Record<string, boolean> = {};
- for (const skillKey of agent.skills) {
- checks[skillKey] = true;
- }
- appSettings.skillChecks = checks;
-
- // Always reset tool permissions to agent's allowlist (even if empty)
- const perms: Record<string, boolean> = {};
- for (const key of Object.keys(appSettings.toolPerms)) {
- perms[key] = false;
- }
- for (const tool of agent.tools) {
- perms[tool] = true;
- }
- appSettings.toolPerms = perms;
-
- // Persist to backend
- fetch(`${config.apiBase}/tabs/${tab.id}`, {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- ...(firstModel ? { keyId: firstModel.key_id, modelId: firstModel.model_id } : {}),
- }),
- }).catch(() => {});
- }
-
- function replyPermission(id: string, reply: "once" | "always" | "reject"): void {
- if (wsClient.connectionStatus !== "connected") return;
- const prompt = pendingPermissions.find((p) => p.id === id);
- wsClient.send({ type: "permission-reply", id, reply });
- pendingPermissions = pendingPermissions.filter((p) => p.id !== id);
- if (prompt) {
- permissionLog = [
- ...permissionLog,
- {
- id: generateId(),
- permission: prompt.permission,
- patterns: prompt.patterns,
- action: reply,
- timestamp: new Date().toISOString(),
- description: prompt.description,
- },
- ];
- }
- }
-
- async function cancelQueuedMessage(tabId: string, messageId: string): Promise<void> {
- const tab = getTabById(tabId);
- if (tab) {
- updateTab(tabId, {
- queuedMessages: tab.queuedMessages.filter((m) => m.id !== messageId),
- live: tab.live.filter((m) => !(m.role === "user" && m.id === `queued-${messageId}`)),
- });
- }
- try {
- await fetch(`${config.apiBase}/chat/cancel`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ tabId, messageId }),
- });
- } catch {
- // ignore
- }
- }
-
- async function stopGeneration(tabId: string): Promise<void> {
- try {
- await fetch(`${config.apiBase}/chat/stop`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ tabId }),
- });
- } catch {
- // ignore
- }
- }
-
- function copyConversation(): string {
- const tab = getActiveTab();
- if (!tab) return "";
-
- const enabledTools = Object.entries(appSettings.savedToolPerms)
- .filter(([, v]) => v)
- .map(([k]) => k);
-
- // Short, distinguishable chunk descriptor — lets us see the shape of
- // each message at a glance without dumping the full content.
- // E.g. `chunks=6: thinking, tool-batch[2], thinking, tool-batch[1], thinking, text`.
- // If a message reports `chunks=0`, the in-memory store has no content
- // for it — which is the canonical symptom of a wire-format / load
- // failure. Always include this so bug reports are diagnosable from
- // the paste alone, without DB access.
- const summarizeChunks = (chunks: (typeof tab.renderGroups)[number]["chunks"]) => {
- if (chunks.length === 0) return "chunks=0";
- const parts = chunks.map((c) => {
- if (c.type === "tool-batch") return `tool-batch[${c.calls.length}]`;
- if (c.type === "system") return `system:${c.kind}`;
- return c.type; // text | thinking | error
- });
- return `chunks=${chunks.length}: ${parts.join(", ")}`;
- };
-
- const shortId = (id: string | undefined) => (id ? `${id.slice(0, 8)}…` : "?");
-
- const lines: string[] = [
- "=== Dispatch Conversation ===",
- `Tab ID: ${tab.id}`,
- `Tab: ${tab.title}`,
- `Model: ${tab.modelId ?? "default"}`,
- `Tools: ${enabledTools.length > 0 ? enabledTools.join(", ") : "none"}`,
- `Injected Skills: ${tab.injectedSkills.length > 0 ? tab.injectedSkills.join(", ") : "none"}`,
- `Total tabs: ${tabs.length}`,
- `All tab IDs: ${tabs.map((t) => t.id).join(", ")}`,
- "",
- // Store-level state — distinguishes "store empty (load/parse
- // failure)" from "store populated, agent stuck mid-stream" from
- // "agent finished cleanly" etc.
- "--- Frontend store state ---",
- `Connected to backend: ${isConnected}`,
- `Tab agentStatus: ${tab.agentStatus}`,
- `Tab currentAssistantId: ${tab.currentAssistantId ?? "null"}`,
- `Render groups in store: ${tab.renderGroups.length}`,
- `Queued messages: ${tab.queuedMessages.length}`,
- `Persistent: ${tab.persistent}`,
- `Working directory: ${tab.workingDirectory ?? "default"}`,
- `Reasoning effort: ${tab.reasoningEffort}`,
- `Todos: ${tab.tasks.length} (${tab.tasks.filter((t) => t.status === "completed").length} completed)`,
- "",
- ];
- const TOOL_RESULT_MAX = 300;
-
- for (const msg of tab.renderGroups) {
- const role = msg.role === "user" ? "User" : msg.role === "system" ? "System" : "Assistant";
- const streamingFlag = msg.isStreaming ? ", streaming=true" : "";
- // Inline message diagnostics — id, streaming, chunk summary —
- // makes wire-format / store-shape bugs immediately visible.
- lines.push(
- `--- ${role} --- (id=${shortId(msg.id)}${streamingFlag}, ${summarizeChunks(msg.chunks)})`,
- );
-
- // Surface non-trivial debugInfo (errors, HTTP failures). Skip
- // when there's nothing interesting — keeps the output readable
- // for happy-path conversations.
- const dbg = msg.debugInfo;
- if (dbg && (dbg.error || dbg.httpStatus !== undefined || dbg.httpBody)) {
- const dbgBits: string[] = [];
- if (dbg.error) dbgBits.push(`error="${dbg.error}"`);
- if (dbg.httpStatus !== undefined) dbgBits.push(`httpStatus=${dbg.httpStatus}`);
- if (dbg.httpBody) {
- const body = dbg.httpBody.length > 200 ? `${dbg.httpBody.slice(0, 200)}…` : dbg.httpBody;
- dbgBits.push(`httpBody="${body}"`);
- }
- lines.push(` [debug]: ${dbgBits.join(" ")}`);
- }
-
- for (const chunk of msg.chunks) {
- switch (chunk.type) {
- case "text":
- lines.push(chunk.text);
- break;
- case "thinking":
- lines.push(` [Thinking]: ${chunk.text}`);
- break;
- case "tool-batch":
- for (const call of chunk.calls) {
- lines.push(` [Tool: ${call.name}]`);
- if (call.result !== undefined) {
- const result = String(call.result);
- if (result.length > TOOL_RESULT_MAX) {
- lines.push(
- ` Result: ${result.slice(0, TOOL_RESULT_MAX)}... [truncated, ${result.length} chars total]`,
- );
- } else {
- lines.push(` Result: ${result}`);
- }
- }
- }
- break;
- case "error": {
- const code = chunk.statusCode !== undefined ? ` (HTTP ${chunk.statusCode})` : "";
- lines.push(` [Error${code}]: ${chunk.message}`);
- break;
- }
- case "system":
- lines.push(` [${chunk.kind}]: ${chunk.text}`);
- break;
- }
- }
- lines.push("");
- }
- return lines.join("\n");
- }
-
- return {
- get tabs() {
- return tabs;
- },
- get activeTabId() {
- return activeTabId;
- },
- get activeTab() {
- return getActiveTab();
- },
- get isConnected() {
- return isConnected;
- },
- get pendingPermissions() {
- return pendingPermissions;
- },
- get permissionLog() {
- return permissionLog;
- },
- get configReloaded() {
- return configReloaded;
- },
- shortHandleFor,
- createNewTab,
- switchTab,
- closeTab,
- renameTab,
- reorderTabs,
- setDraft,
- addAttachment,
- sendMessage,
- cancelQueuedMessage,
- stopGeneration,
- changeModel,
- setKey,
- setReasoningEffort,
- setAgent,
- replyPermission,
- copyConversation,
- promoteTab,
- openAgentTab,
- setWorkingDirectory,
- setCacheWarmingEnabled,
- startCompaction,
- // Exposed so tests can drive the real reactive code path that the
- // WS callback uses in production. Not intended for use in
- // components — they should rely on the WS subscription instead.
- handleEvent,
- hydrateFromBackend,
- loadOlderChunks,
- evictChunks,
- setScrolledUp,
- };
-}
-
-export const tabStore = createTabStore();
diff --git a/packages/frontend/src/lib/theme.ts b/packages/frontend/src/lib/theme.ts
deleted file mode 100644
index 2b4bad0..0000000
--- a/packages/frontend/src/lib/theme.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-/**
- * Single source of truth for the app's theme picker.
- *
- * Two callers care about themes:
- * - `App.svelte`'s `onMount`, which applies the persisted theme on boot
- * so the first paint is the right color.
- * - `SettingsPanel.svelte`'s theme `<select>`, which lets the user
- * change theme at runtime.
- *
- * Both used to hand-roll their own `localStorage` key, default value,
- * and DOM-attribute write. That drift produced a real bug: `App.svelte`
- * left the DOM untouched on first load (so daisyUI fell back to the
- * first theme in `app.css`, `light`), while `SettingsPanel` showed
- * `"dark"` as the selected value — UI and reality disagreed until the
- * user manually picked a theme. Centralizing here closes that gap.
- *
- * The theme list also intentionally mirrors the `@plugin "daisyui"`
- * block in `app.css`. Drift between the two is harmless (a theme name
- * present here but not in CSS just falls back to the default daisyUI
- * theme at render time), but they should be kept in sync by hand —
- * daisyUI's plugin config is a CSS-time concern and can't be imported
- * from TS.
- */
-
-export const THEMES = [
- "light",
- "dark",
- "dracula",
- "night",
- "nord",
- "sunset",
- "cyberpunk",
- "forest",
- "cmyk",
- "coffee",
- "caramellatte",
- "garden",
- "luxury",
-] as const;
-
-export type Theme = (typeof THEMES)[number];
-
-export const THEME_STORAGE_KEY = "dispatch-theme";
-
-/**
- * The fallback theme used both at first-boot apply (when nothing is
- * persisted) and as the UI's default-selected value. They MUST match —
- * if they ever diverged again, the UI would show one theme and the
- * page would render another.
- */
-export const DEFAULT_THEME: Theme = "dark";
-
-/**
- * Read the persisted theme. Returns `DEFAULT_THEME` when nothing is
- * stored, when access throws (private mode / SecurityError), or when
- * the stored value isn't one of the known themes. Never throws.
- *
- * SSR-safe: if `localStorage` is undefined (e.g. `vite build` during
- * prerender, if that's ever wired up), returns the default.
- */
-export function loadStoredTheme(): Theme {
- try {
- if (typeof localStorage === "undefined") return DEFAULT_THEME;
- const raw = localStorage.getItem(THEME_STORAGE_KEY);
- if (raw && (THEMES as readonly string[]).includes(raw)) {
- return raw as Theme;
- }
- return DEFAULT_THEME;
- } catch {
- return DEFAULT_THEME;
- }
-}
-
-/**
- * Apply a theme to the live document and persist it. The DOM write is
- * unconditional (daisyUI keys off `data-theme` on the `<html>`
- * element); the storage write is best-effort and swallows quota /
- * SecurityError failures because the session continues to work either
- * way — only cross-reload persistence degrades.
- */
-export function applyTheme(theme: Theme): void {
- if (typeof document !== "undefined") {
- document.documentElement.setAttribute("data-theme", theme);
- }
- try {
- if (typeof localStorage !== "undefined") {
- localStorage.setItem(THEME_STORAGE_KEY, theme);
- }
- } catch {
- // Best-effort.
- }
-}
diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts
deleted file mode 100644
index bb7c169..0000000
--- a/packages/frontend/src/lib/types.ts
+++ /dev/null
@@ -1,337 +0,0 @@
-export interface DebugInfo {
- timestamp: string;
- error?: string;
- notice?: string;
- model?: string;
- apiBase?: string;
- connectionStatus?: string;
- agentStatus?: string;
- rawEvent?: unknown;
- httpStatus?: number;
- httpBody?: string;
-}
-
-/**
- * Per-tab prompt-cache telemetry, accumulated from the `usage` AgentEvent
- * (one per LLM round-trip). Token counts are cumulative across the session
- * since the page loaded; `last` holds the most recent request's split. Powers
- * the "Cache Rate" sidebar view. The cache hit rate is
- * `cacheReadTokens / inputTokens` (inputTokens is the TOTAL prompt, including
- * cached tokens).
- */
-export interface CacheStats {
- inputTokens: number;
- outputTokens: number;
- cacheReadTokens: number;
- cacheWriteTokens: number;
- /** Number of LLM requests (usage events) counted. */
- requests: number;
- last: {
- inputTokens: number;
- outputTokens: number;
- cacheReadTokens: number;
- cacheWriteTokens: number;
- } | null;
-}
-
-/**
- * Mirror of the core `Chunk` union (see packages/core/src/types/index.ts).
- *
- * Wire-format symmetry MUST be kept with core. If you change one, change
- * the other. The frontend store calls into the shared
- * `appendEventToChunks` helper from core so the two stay in lockstep.
- */
-export type Chunk = TextChunk | ThinkingChunk | ToolBatchChunk | ErrorChunk | SystemChunk;
-
-export interface TextChunk {
- type: "text";
- text: string;
-}
-
-export interface ThinkingChunk {
- type: "thinking";
- text: string;
- /**
- * Mirror of core. Anthropic's `providerMetadata` blob captured from
- * the v6 `reasoning-end` stream event. Present once the backend has
- * sealed the chunk; absent for in-flight thinking or for non-Anthropic
- * models. The UI doesn't render this — it lives here for wire-format
- * symmetry with the persisted chunk shape.
- */
- metadata?: Record<string, unknown>;
-}
-
-export interface ToolBatchChunk {
- type: "tool-batch";
- calls: ToolBatchEntry[];
-}
-
-export interface ToolBatchEntry {
- id: string;
- name: string;
- arguments: Record<string, unknown>;
- result?: string;
- isError?: boolean;
- shellOutput?: { stdout: string; stderr: string };
-}
-
-export interface ErrorChunk {
- type: "error";
- message: string;
- statusCode?: number;
-}
-
-export type SystemChunkKind = "notice" | "model-changed" | "config-reload" | "cancelled";
-
-export interface SystemChunk {
- type: "system";
- kind: SystemChunkKind;
- text: string;
-}
-
-/**
- * A render bubble. NOT stored state — it's a derived projection of the flat
- * chunk log (`groupRowsToMessages(tab.chunks)`) concatenated with the transient
- * live tail. The store's source of truth for history is `tab.chunks: ChunkRow[]`.
- */
-export interface ChatMessage {
- id: string;
- role: "user" | "assistant" | "system";
- chunks: Chunk[];
- isStreaming?: boolean;
- debugInfo?: DebugInfo;
- seq?: number;
- /**
- * turn_id of the chunk rows this message was grouped from (or the in-flight
- * turn for a live message). Gives a stable, turn-scoped render key so the
- * bubble doesn't remount when the live turn reconciles into sealed chunks.
- */
- turnId?: string;
-}
-
-export type ConnectionStatus = "connecting" | "connected" | "disconnected";
-
-/**
- * Mirror of core's `TabStatusSnapshot` (see packages/core/src/types/index.ts).
- *
- * Sent on every WS (re)connect and via `GET /status`. The frontend uses
- * this to:
- * - reconcile its in-memory `agentStatus` with the backend's truth
- * after a disconnect window;
- * - reconstruct the in-flight assistant message for any tab the
- * backend is currently streaming, so the user sees the partial
- * thinking / text without waiting for the next delta.
- *
- * Wire-format symmetry MUST be kept with core. If you change one,
- * change the other.
- */
-export interface TabStatusSnapshot {
- status: "idle" | "running" | "error";
- currentChunks?: Chunk[];
- currentAssistantId?: string;
- /** turn_id of the in-flight turn; present iff status === "running". */
- currentTurnId?: string;
- /** The tab's todo list, for rehydrating the Tasks panel on reload. */
- tasks?: TaskItem[];
-}
-
-export type AgentEvent =
- | { type: "status"; status: "idle" | "running" | "error" }
- // Opens a turn before any content delta; carries the turn_id used to tag
- // the live chunks so they reconcile cleanly when the turn seals.
- | { type: "turn-start"; turnId: string }
- // Fires after the turn settled AND its chunks were persisted (after the DB
- // write, post status:idle). Triggers the frontend's reconcile-from-DB.
- // `usageStats` carries the tab's authoritative usage aggregate (read after the
- // usage rows were persisted); the store REPLACES `cacheStats` with it,
- // reconciling the live accumulator to the DB truth (self-heals the live
- // overshoot from a discarded rate-limited fallback attempt). null ⇒ no usage
- // rows; absent ⇒ leave cacheStats untouched.
- | { type: "turn-sealed"; turnId: string; usageStats?: CacheStats | null }
- // Sent on every WS (re)connect: a snapshot of every tab the backend is
- // currently tracking and its live status. The frontend uses this to
- // detect desync after a reconnect (e.g. bun --watch restart killed the
- // in-flight agent state, frontend missed `done` / `status:idle` events).
- | { type: "statuses"; statuses: Record<string, TabStatusSnapshot> }
- | { type: "text-delta"; delta: string }
- | { type: "reasoning-delta"; delta: string }
- | { type: "reasoning-end"; metadata?: Record<string, unknown> }
- | {
- type: "tool-call";
- toolCall: {
- id: string;
- name: string;
- arguments: Record<string, unknown>;
- };
- }
- | {
- type: "tool-result";
- toolResult: { toolCallId: string; result: string; isError: boolean };
- }
- | {
- type: "usage";
- usage: {
- inputTokens: number;
- outputTokens: number;
- cacheReadTokens: number;
- cacheWriteTokens: number;
- };
- }
- | { type: "error"; error: string }
- | { type: "notice"; message: string }
- | { type: "model-changed"; keyId: string; modelId: string }
- | { type: "task-list-update"; tasks: TaskItem[] }
- | { type: "config-reload" }
- | {
- type: "done";
- message: {
- role: string;
- content: string;
- toolCalls?: unknown[];
- toolResults?: unknown[];
- };
- }
- | { type: "permission-prompt"; pending: PermissionPrompt[] }
- | { type: "shell-output"; data: string; stream: "stdout" | "stderr" }
- | {
- type: "tab-created";
- id: string;
- title: string;
- keyId: string | null;
- modelId: string | null;
- parentTabId: string | null;
- agentSlug?: string | null;
- workingDirectory?: string | null;
- agentModels?: Array<{ key_id: string; model_id: string }> | null;
- }
- | { type: "message-queued"; tabId: string; messageId: string; message: string }
- | {
- type: "message-consumed";
- tabId: string;
- messageIds: string[];
- /**
- * Why the queue was drained:
- * - "interrupt": consumed mid-turn, folded into a running turn's tool
- * result as a [USER INTERRUPT]. The optimistic bubble collapses into
- * that sealed turn.
- * - "continuation": consumed between turns to START a new turn. The
- * optimistic bubble becomes that new turn's initiating user row.
- * Absent ⇒ treat as "interrupt" (back-compat).
- */
- reason?: "interrupt" | "continuation";
- }
- | { type: "message-cancelled"; tabId: string; messageId: string }
- | { type: "compaction-started"; tempTabId: string; sourceTabId: string }
- | {
- type: "compaction-complete";
- tempTabId: string;
- sourceTabId: string;
- backupTabId: string;
- backupTitle: string;
- }
- | { type: "compaction-error"; tempTabId: string; sourceTabId: string; error: string };
-
-export interface TaskItem {
- /** Stable positional id for Svelte keying only; never shown to the model. */
- id: string;
- content: string;
- status: "pending" | "in_progress" | "completed" | "cancelled";
-}
-
-export interface PermissionPrompt {
- id: string;
- permission: string;
- patterns: string[];
- always: string[];
- description: string;
- metadata: Record<string, unknown>;
-}
-
-export interface ModelOverride {
- keyId: string;
- modelId: string;
-}
-
-export interface KeyInfo {
- id: string;
- provider: string;
- status: "active" | "exhausted";
- lastError: string | null;
- exhaustedAt: number | null;
-}
-
-export interface QueuedMessage {
- id: string;
- message: string;
- timestamp: number;
-}
-
-export interface LogEntry {
- id: string;
- permission: string;
- patterns: string[];
- action: "once" | "always" | "reject";
- timestamp: string;
- description: string;
-}
-
-export interface UsageBucket {
- utilization?: number;
- resetsAt?: number;
-}
-
-export interface ClaudeAccountUsage {
- label: string;
- source: string;
- subscriptionType?: string;
- fiveHour?: UsageBucket;
- sevenDay?: UsageBucket;
- error?: string;
-}
-
-export interface ClaudeUsageData {
- provider: "anthropic";
- accounts?: ClaudeAccountUsage[];
- fiveHour?: UsageBucket;
- sevenDay?: UsageBucket;
-}
-
-export interface OpencodeUsageData {
- provider: "opencode-go";
- unavailable?: boolean;
- consoleUrl?: string;
- limits?: { fiveHour?: string; weekly?: string; monthly?: string };
- fiveHour?: UsageBucket;
- weekly?: UsageBucket;
- monthly?: UsageBucket;
-}
-
-export interface CopilotUsageData {
- provider: "github-copilot";
- tokensConsumed?: number;
- tokensRemaining?: number;
- percentUsed?: number;
- resetAt?: number;
- plan?: string;
-}
-
-export interface GoogleUsageData {
- provider: "google";
- models?: Array<{
- name: string;
- inputTokenLimit: number;
- outputTokenLimit: number;
- rpm: number;
- requestsPerDay: number;
- }>;
- currentUsage?: {
- percentUsed: number;
- resetsAt?: string;
- };
- weeklyUsage?: {
- percentUsed: number;
- resetsAt?: string;
- };
-}
-
-export type KeyUsageData = ClaudeUsageData | OpencodeUsageData | CopilotUsageData | GoogleUsageData;
diff --git a/packages/frontend/src/lib/ws.svelte.ts b/packages/frontend/src/lib/ws.svelte.ts
deleted file mode 100644
index ff142ad..0000000
--- a/packages/frontend/src/lib/ws.svelte.ts
+++ /dev/null
@@ -1,127 +0,0 @@
-import { config } from "./config.js";
-import type { AgentEvent, ConnectionStatus } from "./types.js";
-
-type EventCallback = (event: AgentEvent) => void;
-
-// Close any stale WebSocket from HMR reloads
-if (import.meta.hot) {
- import.meta.hot.dispose((data: Record<string, unknown>) => {
- const old = data._ws as WebSocket | undefined;
- if (old && old.readyState === WebSocket.OPEN) {
- old.close();
- }
- });
-}
-
-function createWebSocketClient(url: string) {
- let connectionStatus: ConnectionStatus = $state("disconnected");
- let ws: WebSocket | null = null;
- let reconnectDelay = 1000;
- let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
- let manualDisconnect = false;
- const callbacks = new Set<EventCallback>();
-
- function connect() {
- if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
- return;
- }
- manualDisconnect = false;
- connectionStatus = "connecting";
- ws = new WebSocket(url);
-
- // Store ref for HMR cleanup
- if (import.meta.hot) {
- import.meta.hot.data._ws = ws;
- }
-
- ws.onopen = () => {
- connectionStatus = "connected";
- reconnectDelay = 1000;
- };
-
- ws.onmessage = (event: MessageEvent) => {
- let data: AgentEvent;
- try {
- data = JSON.parse(event.data as string) as AgentEvent;
- } catch (err) {
- // Genuinely malformed WS payload — these are rare and harmless.
- console.warn("[ws] ignored malformed message:", err);
- return;
- }
- // Run callbacks OUTSIDE the parse try so callback throws are visible.
- // The previous catch-everything wrapper silently swallowed bugs in
- // downstream handlers (notably the `structuredClone` of a Svelte 5
- // $state proxy throwing DataCloneError), making them undiagnosable.
- for (const cb of callbacks) {
- try {
- cb(data);
- } catch (err) {
- console.error("[ws] callback threw for event:", data, err);
- }
- }
- };
-
- ws.onclose = () => {
- connectionStatus = "disconnected";
- ws = null;
- if (!manualDisconnect) {
- scheduleReconnect();
- }
- };
-
- ws.onerror = () => {
- ws?.close();
- };
- }
-
- function scheduleReconnect() {
- if (reconnectTimer) clearTimeout(reconnectTimer);
- reconnectTimer = setTimeout(() => {
- reconnectTimer = null;
- connect();
- }, reconnectDelay);
- reconnectDelay = Math.min(reconnectDelay * 2, 10000);
- }
-
- function disconnect() {
- manualDisconnect = true;
- if (reconnectTimer) {
- clearTimeout(reconnectTimer);
- reconnectTimer = null;
- }
- ws?.close();
- ws = null;
- connectionStatus = "disconnected";
- }
-
- function onEvent(callback: EventCallback) {
- callbacks.add(callback);
- return () => {
- callbacks.delete(callback);
- };
- }
-
- /** Remove all registered event callbacks (used for HMR safety). */
- function clearCallbacks() {
- callbacks.clear();
- }
-
- function send(data: unknown): void {
- if (ws && ws.readyState === WebSocket.OPEN) {
- ws.send(JSON.stringify(data));
- }
- }
-
- return {
- get connectionStatus() {
- return connectionStatus;
- },
- connect,
- disconnect,
- onEvent,
- clearCallbacks,
- send,
- };
-}
-
-export const wsClient = createWebSocketClient(config.wsUrl);
diff --git a/packages/frontend/src/main.ts b/packages/frontend/src/main.ts
deleted file mode 100644
index fd54362..0000000
--- a/packages/frontend/src/main.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import App from "./App.svelte";
-import "./app.css";
-import { mount } from "svelte";
-
-const app = mount(App, {
- target: document.getElementById("app") as HTMLElement,
-});
-
-export default app;
diff --git a/packages/frontend/src/vite-env.d.ts b/packages/frontend/src/vite-env.d.ts
deleted file mode 100644
index 4078e74..0000000
--- a/packages/frontend/src/vite-env.d.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-/// <reference types="svelte" />
-/// <reference types="vite/client" />
diff --git a/packages/frontend/svelte.config.js b/packages/frontend/svelte.config.js
deleted file mode 100644
index f77d881..0000000
--- a/packages/frontend/svelte.config.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
-
-export default {
- preprocess: vitePreprocess(),
-};
diff --git a/packages/frontend/tests/attachment-tokens.test.ts b/packages/frontend/tests/attachment-tokens.test.ts
deleted file mode 100644
index 7208cf3..0000000
--- a/packages/frontend/tests/attachment-tokens.test.ts
+++ /dev/null
@@ -1,130 +0,0 @@
-import { describe, expect, it } from "vitest";
-import {
- computeTokenDeletion,
- findTokens,
- generateTokenId,
- intactTokenIds,
- makeAttachmentToken,
- markerFor,
- parseDraft,
- type StagedAttachment,
-} from "../src/lib/attachment-tokens.js";
-
-function img(id: string): StagedAttachment {
- return { id, kind: "image", mediaType: "image/png", data: "QQ==" };
-}
-function pdf(id: string): StagedAttachment {
- return { id, kind: "pdf", mediaType: "application/pdf", data: "QQ==", name: "doc.pdf" };
-}
-
-describe("token helpers", () => {
- it("round-trips make/find", () => {
- const tok = makeAttachmentToken("image", "abc123");
- expect(tok).toBe("【image:abc123】");
- const found = findTokens(`x ${tok} y`);
- expect(found).toHaveLength(1);
- expect(found[0]).toMatchObject({ id: "abc123", kind: "image", start: 2, end: 2 + tok.length });
- });
-
- it("generates 6-char lowercase-alnum ids", () => {
- for (let i = 0; i < 20; i++) {
- expect(generateTokenId()).toMatch(/^[a-z0-9]{6}$/);
- }
- });
-
- it("finds multiple tokens in order and reports intact ids", () => {
- const text = `a ${makeAttachmentToken("image", "aaaaaa")} b ${makeAttachmentToken("pdf", "bbbbbb")}`;
- const found = findTokens(text);
- expect(found.map((t) => t.id)).toEqual(["aaaaaa", "bbbbbb"]);
- expect(intactTokenIds(text)).toEqual(new Set(["aaaaaa", "bbbbbb"]));
- });
-
- it("does not treat a partially-broken token as intact", () => {
- // Missing closing bracket → not a valid token.
- expect(intactTokenIds("【image:aaaaaa").size).toBe(0);
- });
-});
-
-describe("computeTokenDeletion", () => {
- const tok = makeAttachmentToken("image", "abcabc");
- const text = `hi ${tok}!`; // token spans indices 3..3+len
- const tokStart = 3;
- const tokEnd = 3 + tok.length;
-
- it("returns null when no tokens exist", () => {
- expect(computeTokenDeletion("plain", 2, 2, "Backspace")).toBeNull();
- });
-
- it("Backspace just after a token removes the whole token atomically", () => {
- const res = computeTokenDeletion(text, tokEnd, tokEnd, "Backspace");
- expect(res).not.toBeNull();
- expect(res?.text).toBe("hi !");
- expect(res?.caret).toBe(tokStart);
- expect(res?.removedIds).toEqual(["abcabc"]);
- });
-
- it("Delete just before a token removes the whole token atomically", () => {
- const res = computeTokenDeletion(text, tokStart, tokStart, "Delete");
- expect(res?.text).toBe("hi !");
- expect(res?.caret).toBe(tokStart);
- expect(res?.removedIds).toEqual(["abcabc"]);
- });
-
- it("Backspace NOT adjacent to a token returns null (default editing)", () => {
- // Caret at index 2 (after "hi"), token is further along.
- expect(computeTokenDeletion(text, 2, 2, "Backspace")).toBeNull();
- });
-
- it("a selection overlapping a token expands to cover the whole token", () => {
- // Select from inside "hi " through the middle of the token.
- const res = computeTokenDeletion(text, 1, tokStart + 3, "Backspace");
- expect(res).not.toBeNull();
- // Deletion starts at min(selStart, tokStart)=1 and ends at tokEnd.
- expect(res?.text).toBe("h!");
- expect(res?.removedIds).toEqual(["abcabc"]);
- });
-
- it("a range selection touching no token returns null", () => {
- expect(computeTokenDeletion(text, 0, 2, "Backspace")).toBeNull();
- });
-});
-
-describe("parseDraft", () => {
- it("returns plain text + null content when there are no attachments", () => {
- const res = parseDraft("just text", new Map());
- expect(res.displayText).toBe("just text");
- expect(res.content).toBeNull();
- });
-
- it("interleaves text and attachment parts in order", () => {
- const a = img("aaaaaa");
- const b = pdf("bbbbbb");
- const map = new Map([
- [a.id, a],
- [b.id, b],
- ]);
- const draft = `A: ${makeAttachmentToken("image", a.id)} B: ${makeAttachmentToken("pdf", b.id)} end`;
- const res = parseDraft(draft, map);
-
- // displayText swaps tokens for markers.
- expect(res.displayText).toBe(`A: ${markerFor("image")} B: ${markerFor("pdf")} end`);
-
- // content interleaves the surrounding text with the attachment parts.
- expect(res.content).toEqual([
- { type: "text", text: "A: " },
- { type: "attachment", mediaType: "image/png", data: "QQ==" },
- { type: "text", text: " B: " },
- { type: "attachment", mediaType: "application/pdf", data: "QQ==", name: "doc.pdf" },
- { type: "text", text: " end" },
- ]);
- });
-
- it("treats an orphan token (no staged attachment) as plain text", () => {
- // Token present in text but not in the attachments map.
- const draft = `x ${makeAttachmentToken("image", "zzzzzz")} y`;
- const res = parseDraft(draft, new Map());
- expect(res.displayText).toBe(`x ${markerFor("image")} y`);
- // No real attachment → null content (plain-text send).
- expect(res.content).toBeNull();
- });
-});
diff --git a/packages/frontend/tests/cache-warm-storage.test.ts b/packages/frontend/tests/cache-warm-storage.test.ts
deleted file mode 100644
index 458d07d..0000000
--- a/packages/frontend/tests/cache-warm-storage.test.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-/**
- * Tests for `src/lib/cache-warm-storage.ts` — the per-tab localStorage
- * round-trip for the "keep prompt cache warm" toggle.
- *
- * As in `sidebar-storage.test.ts`, Bun's `localStorage` shim is partial, so we
- * install a clean in-memory polyfill per-test.
- */
-import { beforeEach, describe, expect, it, vi } from "vitest";
-import {
- clearCacheWarmEnabled,
- loadCacheWarmEnabled,
- saveCacheWarmEnabled,
-} from "../src/lib/cache-warm-storage.js";
-
-const LS_KEY = "dispatch-cache-warming";
-
-function makeLocalStorageMock(): Storage {
- const store = new Map<string, string>();
- return {
- getItem: (k: string) => store.get(k) ?? null,
- setItem: (k: string, v: string) => {
- store.set(k, v);
- },
- removeItem: (k: string) => {
- store.delete(k);
- },
- clear: () => {
- store.clear();
- },
- get length() {
- return store.size;
- },
- key: (i: number) => Array.from(store.keys())[i] ?? null,
- };
-}
-
-beforeEach(() => {
- vi.stubGlobal("localStorage", makeLocalStorageMock());
-});
-
-describe("loadCacheWarmEnabled", () => {
- it("defaults to false when nothing is stored", () => {
- expect(loadCacheWarmEnabled("tab-1")).toBe(false);
- });
-
- it("returns true after enabling a tab", () => {
- saveCacheWarmEnabled("tab-1", true);
- expect(loadCacheWarmEnabled("tab-1")).toBe(true);
- });
-
- it("is scoped per-tab (one tab's flag doesn't leak to another)", () => {
- saveCacheWarmEnabled("tab-1", true);
- expect(loadCacheWarmEnabled("tab-2")).toBe(false);
- });
-
- it("returns false when the stored JSON is malformed", () => {
- localStorage.setItem(LS_KEY, "not json {[");
- expect(loadCacheWarmEnabled("tab-1")).toBe(false);
- });
-
- it("ignores non-boolean entries", () => {
- localStorage.setItem(LS_KEY, JSON.stringify({ "tab-1": "yes", "tab-2": true }));
- expect(loadCacheWarmEnabled("tab-1")).toBe(false);
- expect(loadCacheWarmEnabled("tab-2")).toBe(true);
- });
-});
-
-describe("saveCacheWarmEnabled", () => {
- it("removes the entry when set to false (default-off representation)", () => {
- saveCacheWarmEnabled("tab-1", true);
- saveCacheWarmEnabled("tab-1", false);
- const raw = localStorage.getItem(LS_KEY);
- expect(raw).toBe(JSON.stringify({}));
- expect(loadCacheWarmEnabled("tab-1")).toBe(false);
- });
-
- it("preserves other tabs' flags when toggling one", () => {
- saveCacheWarmEnabled("tab-1", true);
- saveCacheWarmEnabled("tab-2", true);
- saveCacheWarmEnabled("tab-1", false);
- expect(loadCacheWarmEnabled("tab-1")).toBe(false);
- expect(loadCacheWarmEnabled("tab-2")).toBe(true);
- });
-
- it("silently ignores storage write errors", () => {
- vi.stubGlobal("localStorage", {
- getItem: () => null,
- setItem: () => {
- throw new Error("QuotaExceededError");
- },
- removeItem: () => {},
- clear: () => {},
- length: 0,
- key: () => null,
- });
- expect(() => saveCacheWarmEnabled("tab-1", true)).not.toThrow();
- });
-});
-
-describe("clearCacheWarmEnabled", () => {
- it("drops a tab's persisted flag", () => {
- saveCacheWarmEnabled("tab-1", true);
- clearCacheWarmEnabled("tab-1");
- expect(loadCacheWarmEnabled("tab-1")).toBe(false);
- });
-
- it("is a no-op when the tab has no stored flag", () => {
- expect(() => clearCacheWarmEnabled("tab-x")).not.toThrow();
- expect(localStorage.getItem(LS_KEY)).toBeNull();
- });
-});
diff --git a/packages/frontend/tests/cache-warming.test.ts b/packages/frontend/tests/cache-warming.test.ts
deleted file mode 100644
index 583d563..0000000
--- a/packages/frontend/tests/cache-warming.test.ts
+++ /dev/null
@@ -1,232 +0,0 @@
-/**
- * Tests for `src/lib/cache-warming.svelte.ts` — the prompt-cache warming
- * timer/orchestration store.
- *
- * Drives the REAL `$state`-backed store via `createCacheWarmingStore()` under
- * fake timers, asserting the idle/turn/send lifecycle, the repeating 4-minute
- * cadence, the warming-specific last-% capture, and error surfacing.
- */
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-
-// `config.ts` reads localStorage at import; mock it (mirrors chat-store.test).
-vi.mock("../src/lib/config.js", () => ({
- config: {
- apiBase: "http://test.local:3000",
- wsUrl: "ws://test.local:3000/ws",
- defaultApiBase: "http://test.local:3000",
- setApiBase: vi.fn(),
- },
-}));
-
-function makeLocalStorageMock(): Storage {
- const store = new Map<string, string>();
- return {
- getItem: (k: string) => store.get(k) ?? null,
- setItem: (k: string, v: string) => {
- store.set(k, v);
- },
- removeItem: (k: string) => {
- store.delete(k);
- },
- clear: () => {
- store.clear();
- },
- get length() {
- return store.size;
- },
- key: (i: number) => Array.from(store.keys())[i] ?? null,
- };
-}
-
-import { createCacheWarmingStore, WARM_INTERVAL_MS } from "../src/lib/cache-warming.svelte.js";
-
-type WarmStore = ReturnType<typeof createCacheWarmingStore>;
-
-function makeFetchOk(usage: { inputTokens: number; cacheReadTokens: number }): typeof fetch {
- return vi.fn(() =>
- Promise.resolve({
- ok: true,
- status: 200,
- json: () => Promise.resolve({ usage }),
- }),
- ) as unknown as typeof fetch;
-}
-
-/** Flush microtasks so awaited fetch `.json()` chains settle under fake timers. */
-async function flush(): Promise<void> {
- await Promise.resolve();
- await Promise.resolve();
- await Promise.resolve();
-}
-
-let store: WarmStore;
-
-beforeEach(() => {
- vi.useFakeTimers();
- vi.stubGlobal("localStorage", makeLocalStorageMock());
- store = createCacheWarmingStore();
-});
-
-afterEach(() => {
- vi.useRealTimers();
- vi.unstubAllGlobals();
-});
-
-describe("enable / disable lifecycle", () => {
- it("arms a countdown when enabled and idle", () => {
- store.setEnabled("tab-1", true);
- const s = store.stateFor("tab-1");
- expect(s.enabled).toBe(true);
- expect(s.nextFireAt).not.toBeNull();
- });
-
- it("cancels the countdown when disabled", () => {
- store.setEnabled("tab-1", true);
- store.setEnabled("tab-1", false);
- expect(store.stateFor("tab-1").nextFireAt).toBeNull();
- });
-
- it("does not arm a disabled tab", () => {
- const s = store.stateFor("tab-2");
- expect(s.enabled).toBe(false);
- expect(s.nextFireAt).toBeNull();
- });
-});
-
-describe("firing cadence", () => {
- it("fires after the interval and captures the warming last-%", async () => {
- const fetchMock = makeFetchOk({ inputTokens: 1000, cacheReadTokens: 900 });
- vi.stubGlobal("fetch", fetchMock);
-
- store.setRequestResolver(() => ({
- keyId: "k",
- modelId: "m",
- agentModels: null,
- reasoningEffort: "high",
- }));
- store.setEnabled("tab-1", true);
-
- await vi.advanceTimersByTimeAsync(WARM_INTERVAL_MS);
- await flush();
-
- expect(fetchMock).toHaveBeenCalledTimes(1);
- const [url, opts] = (fetchMock as unknown as { mock: { calls: unknown[][] } }).mock
- .calls[0] as [string, { body: string }];
- expect(url).toContain("/chat/warm");
- // The request forwards the SAME effort the real turn uses — it's an
- // Anthropic message-cache key, so warming must match it to refresh the
- // bucket the next real message reads.
- expect(JSON.parse(opts.body)).toMatchObject({
- tabId: "tab-1",
- keyId: "k",
- modelId: "m",
- reasoningEffort: "high",
- });
-
- const s = store.stateFor("tab-1");
- expect(s.lastPct).toBe(90); // 900 / 1000
- expect(s.error).toBeNull();
- });
-
- it("re-arms repeatedly (not a one-shot)", async () => {
- const fetchMock = makeFetchOk({ inputTokens: 1000, cacheReadTokens: 800 });
- vi.stubGlobal("fetch", fetchMock);
- store.setEnabled("tab-1", true);
-
- await vi.advanceTimersByTimeAsync(WARM_INTERVAL_MS);
- await flush();
- expect(fetchMock).toHaveBeenCalledTimes(1);
- // After the first fire it re-arms, so a second interval fires again.
- expect(store.stateFor("tab-1").nextFireAt).not.toBeNull();
-
- await vi.advanceTimersByTimeAsync(WARM_INTERVAL_MS);
- await flush();
- expect(fetchMock).toHaveBeenCalledTimes(2);
- });
-
- it("shows '-%' (null) before it has ever fired", () => {
- store.setEnabled("tab-1", true);
- expect(store.stateFor("tab-1").lastPct).toBeNull();
- });
-});
-
-describe("turn-state gating", () => {
- it("does NOT fire while a turn is active", async () => {
- const fetchMock = makeFetchOk({ inputTokens: 10, cacheReadTokens: 5 });
- vi.stubGlobal("fetch", fetchMock);
- store.setEnabled("tab-1", true);
- store.onTurnActive("tab-1");
- expect(store.stateFor("tab-1").nextFireAt).toBeNull();
-
- await vi.advanceTimersByTimeAsync(WARM_INTERVAL_MS * 2);
- await flush();
- expect(fetchMock).not.toHaveBeenCalled();
- });
-
- it("re-arms when the turn ends (idle)", () => {
- store.setEnabled("tab-1", true);
- store.onTurnActive("tab-1");
- store.onTurnEnded("tab-1");
- expect(store.stateFor("tab-1").nextFireAt).not.toBeNull();
- });
-
- it("a real user message disables+resets the pending timer", async () => {
- const fetchMock = makeFetchOk({ inputTokens: 10, cacheReadTokens: 5 });
- vi.stubGlobal("fetch", fetchMock);
- store.setEnabled("tab-1", true);
- store.onUserMessage("tab-1");
- expect(store.stateFor("tab-1").nextFireAt).toBeNull();
-
- await vi.advanceTimersByTimeAsync(WARM_INTERVAL_MS);
- await flush();
- // The pending fire was cancelled by onUserMessage; nothing fired.
- expect(fetchMock).not.toHaveBeenCalled();
- });
-});
-
-describe("error surfacing", () => {
- it("captures a non-OK HTTP error into state.error", async () => {
- const fetchMock = vi.fn(() =>
- Promise.resolve({
- ok: false,
- status: 500,
- json: () => Promise.resolve({ error: "provider exploded" }),
- }),
- ) as unknown as typeof fetch;
- vi.stubGlobal("fetch", fetchMock);
-
- store.setEnabled("tab-1", true);
- await vi.advanceTimersByTimeAsync(WARM_INTERVAL_MS);
- await flush();
-
- expect(store.stateFor("tab-1").error).toBe("provider exploded");
- });
-
- it("captures a network rejection into state.error", async () => {
- const fetchMock = vi.fn(() =>
- Promise.reject(new Error("network down")),
- ) as unknown as typeof fetch;
- vi.stubGlobal("fetch", fetchMock);
-
- store.setEnabled("tab-1", true);
- await vi.advanceTimersByTimeAsync(WARM_INTERVAL_MS);
- await flush();
-
- expect(store.stateFor("tab-1").error).toBe("network down");
- });
-});
-
-describe("forgetTab / removeTab", () => {
- it("forgetTab clears state, timers, and persisted preference for a closed tab", async () => {
- const fetchMock = makeFetchOk({ inputTokens: 10, cacheReadTokens: 5 });
- vi.stubGlobal("fetch", fetchMock);
- store.setEnabled("tab-1", true);
- store.forgetTab("tab-1");
-
- // A fresh stateFor after removal is a default-off entry.
- expect(store.stateFor("tab-1").enabled).toBe(false);
- await vi.advanceTimersByTimeAsync(WARM_INTERVAL_MS * 2);
- await flush();
- expect(fetchMock).not.toHaveBeenCalled();
- });
-});
diff --git a/packages/frontend/tests/chat-store.test.ts b/packages/frontend/tests/chat-store.test.ts
deleted file mode 100644
index 8639bff..0000000
--- a/packages/frontend/tests/chat-store.test.ts
+++ /dev/null
@@ -1,2203 +0,0 @@
-/**
- * Integration tests for the tab store at `src/lib/tabs.svelte.ts`.
- *
- * These tests drive the **real** Svelte 5 `$state`-backed store via
- * `createTabStore()` and `handleEvent()`. The previous version of this
- * file used a POJO harness (plain arrays) that duplicated the store
- * logic from the production file, which meant any drift between the
- * harness and the real code went undetected.
- *
- * What this catches:
- * - Logic bugs inside `handleEvent`, `applyChunkEvent`, `routeSystemEvent`.
- * - Reactivity contract bugs where the real `$state` proxies behave
- * differently than POJO arrays (e.g., reassignment patterns, derived
- * state propagation).
- * - Any case where the helper-imported-from-core (`appendEventToChunks`,
- * `applySystemEvent`) disagrees with how the store wires it.
- *
- * What this DOES NOT catch (a known limitation):
- * - The specific `structuredClone(svelteProxy)` failure that hit
- * production. Browsers' `structuredClone` rejects certain
- * Proxy/internal-slot patterns Svelte 5 uses; Bun's `structuredClone`
- * (which is what vitest runs against here) is more permissive and
- * happily clones these proxies. Empirically: temporarily reverting
- * `applyChunkEvent` to `structuredClone(m.chunks)` keeps these tests
- * green even though the same change breaks the browser at runtime.
- * Catching that class of bug would require a browser-runtime test
- * (Playwright or vitest browser mode) — out of scope for this round,
- * but worth filing.
- *
- * Mocks: `wsClient`, `config`, and the global `fetch` are mocked so the
- * store doesn't try to open a real WebSocket / read localStorage / hit
- * an HTTP backend during module init.
- */
-import { beforeEach, describe, expect, it, vi } from "vitest";
-
-// `config.ts` reads `localStorage` at module load. Bun's runtime defines
-// `localStorage` as a partial shim that lacks `getItem`, so the real
-// module throws before we can even import the store. Mocking the
-// module itself sidesteps that entirely — tests don't depend on the
-// real API base resolution logic.
-vi.mock("../src/lib/config.js", () => ({
- config: {
- apiBase: "http://test.local:3000",
- wsUrl: "ws://test.local:3000/ws",
- defaultApiBase: "http://test.local:3000",
- setApiBase: vi.fn(),
- },
-}));
-
-// Mock the WS module before importing the store so the module-load
-// side effects (clearCallbacks/onEvent registration, status effect)
-// see the stub. Tests interact with the store via the returned
-// `handleEvent` method directly — no WS wiring needed.
-vi.mock("../src/lib/ws.svelte.js", () => ({
- wsClient: {
- connectionStatus: "connected",
- clearCallbacks: vi.fn(),
- onEvent: vi.fn(),
- send: vi.fn(),
- connect: vi.fn(),
- disconnect: vi.fn(),
- },
-}));
-
-// Mock fetch so async network operations (createNewTab POSTs, auto-skill
-// loaders, etc.) resolve immediately without hitting a real backend.
-beforeEach(() => {
- vi.stubGlobal(
- "fetch",
- vi.fn(() => Promise.reject(new Error("test: fetch mocked"))),
- );
-});
-
-import { computeContextUsage } from "../src/lib/context-window.js";
-import { appSettings } from "../src/lib/settings.svelte.js";
-import { createTabStore } from "../src/lib/tabs.svelte.js";
-import type { Chunk, PermissionPrompt } from "../src/lib/types.js";
-import { wsClient } from "../src/lib/ws.svelte.js";
-
-/**
- * Create a fresh store and a tab to drive events into. Returns both the
- * store and the tab id; tests should use `tabId` for `handleEvent` calls.
- */
-async function setupStoreWithTab() {
- const store = createTabStore();
- const tab = await store.createNewTab();
- return { store, tabId: tab.id };
-}
-
-/**
- * Read the active assistant message's chunks. Most chunk tests are
- * structured "drive events → inspect chunks of the resulting assistant
- * message" — this helper avoids the boilerplate.
- */
-function getAssistantChunks(store: ReturnType<typeof createTabStore>): Chunk[] | undefined {
- const tab = store.tabs[0];
- const assistant = tab?.renderGroups.find((m) => m.role === "assistant");
- return assistant?.chunks;
-}
-
-/**
- * Build a raw `ChunkRow` as the `GET /tabs/:id/chunks` endpoint returns them.
- * The chunk-native store loads/paginates raw rows and groups them for render.
- */
-function chunkRow(
- id: string,
- tabId: string,
- seq: number,
- turnId: string,
- role: "user" | "assistant" | "tool" | "system",
- type: "text" | "thinking" | "tool_call" | "tool_result" | "error" | "system",
- data: unknown,
- step = 0,
-): Record<string, unknown> {
- return { id, tabId, seq, turnId, step, role, type, data, createdAt: seq };
-}
-
-describe("tabStore — streaming chunk flow (real $state)", () => {
- it("text-delta creates a streaming assistant message and appends deltas", async () => {
- const { store, tabId } = await setupStoreWithTab();
-
- store.handleEvent({ type: "text-delta", delta: "Hello", tabId });
-
- const assistant = store.tabs[0]?.renderGroups.find((m) => m.role === "assistant");
- expect(assistant).toBeDefined();
- expect(assistant?.isStreaming).toBe(true);
- expect(assistant?.chunks).toEqual([{ type: "text", text: "Hello" }]);
-
- store.handleEvent({ type: "text-delta", delta: " world", tabId });
- expect(getAssistantChunks(store)).toEqual([{ type: "text", text: "Hello world" }]);
- });
-
- it("consecutive text-deltas coalesce into one text chunk", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({ type: "text-delta", delta: "A", tabId });
- store.handleEvent({ type: "text-delta", delta: "B", tabId });
- const chunks = getAssistantChunks(store);
- expect(chunks).toHaveLength(1);
- expect(chunks?.[0]).toEqual({ type: "text", text: "AB" });
- });
-
- it("tool-call after text creates a tool-batch chunk", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({ type: "text-delta", delta: "Calling tool...", tabId });
- store.handleEvent({
- type: "tool-call",
- toolCall: { id: "tc1", name: "search", arguments: { query: "test" } },
- tabId,
- });
- const chunks = getAssistantChunks(store);
- expect(chunks).toHaveLength(2);
- expect(chunks?.[0]?.type).toBe("text");
- expect(chunks?.[1]?.type).toBe("tool-batch");
- if (chunks?.[1]?.type === "tool-batch") {
- expect(chunks[1].calls).toHaveLength(1);
- expect(chunks[1].calls[0]?.id).toBe("tc1");
- expect(chunks[1].calls[0]?.name).toBe("search");
- }
- });
-
- it("tool-result fills in result on the matching tool-batch entry", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({ type: "text-delta", delta: "...", tabId });
- store.handleEvent({
- type: "tool-call",
- toolCall: { id: "tc1", name: "search", arguments: { query: "x" } },
- tabId,
- });
- store.handleEvent({
- type: "tool-result",
- toolResult: { toolCallId: "tc1", result: "found it", isError: false },
- tabId,
- });
- const chunks = getAssistantChunks(store);
- const tb = chunks?.[1];
- if (tb?.type === "tool-batch") {
- expect(tb.calls[0]?.result).toBe("found it");
- expect(tb.calls[0]?.isError).toBe(false);
- } else {
- expect.fail("Expected tool-batch chunk");
- }
- });
-
- it("two consecutive tool-calls coalesce into one tool-batch with two entries", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({
- type: "tool-call",
- toolCall: { id: "tc1", name: "read", arguments: {} },
- tabId,
- });
- store.handleEvent({
- type: "tool-call",
- toolCall: { id: "tc2", name: "write", arguments: {} },
- tabId,
- });
- const chunks = getAssistantChunks(store);
- expect(chunks).toHaveLength(1);
- expect(chunks?.[0]?.type).toBe("tool-batch");
- if (chunks?.[0]?.type === "tool-batch") {
- expect(chunks[0].calls).toHaveLength(2);
- }
- });
-
- it("text after tool-call opens a new text chunk", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({
- type: "tool-call",
- toolCall: { id: "tc1", name: "read", arguments: {} },
- tabId,
- });
- store.handleEvent({ type: "text-delta", delta: "Result: here", tabId });
- const chunks = getAssistantChunks(store);
- expect(chunks).toHaveLength(2);
- expect(chunks?.[0]?.type).toBe("tool-batch");
- expect(chunks?.[1]).toEqual({ type: "text", text: "Result: here" });
- });
-
- it("done finalizes the current assistant message", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({ type: "text-delta", delta: "partial", tabId });
- store.handleEvent({
- type: "done",
- message: { role: "assistant", chunks: [] },
- tabId,
- } as Parameters<typeof store.handleEvent>[0]);
- const assistant = store.tabs[0]?.renderGroups.find((m) => m.role === "assistant");
- expect(assistant?.chunks).toEqual([{ type: "text", text: "partial" }]);
- expect(assistant?.isStreaming).toBe(false);
- expect(store.tabs[0]?.currentAssistantId).toBeNull();
- });
-
- it("status:idle clears currentAssistantId", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({ type: "status", status: "running", tabId });
- expect(store.tabs[0]?.agentStatus).toBe("running");
- store.handleEvent({ type: "status", status: "idle", tabId });
- expect(store.tabs[0]?.agentStatus).toBe("idle");
- expect(store.tabs[0]?.currentAssistantId).toBeNull();
- });
-
- it("reasoning-delta accumulates a thinking chunk", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({ type: "reasoning-delta", delta: "First thought.", tabId });
- expect(getAssistantChunks(store)).toEqual([{ type: "thinking", text: "First thought." }]);
-
- store.handleEvent({ type: "reasoning-delta", delta: " Second thought.", tabId });
- expect(getAssistantChunks(store)).toEqual([
- { type: "thinking", text: "First thought. Second thought." },
- ]);
- });
-
- it("reasoning-end seals the thinking chunk with metadata (end-to-end signature seal)", async () => {
- const { store, tabId } = await setupStoreWithTab();
-
- store.handleEvent({ type: "reasoning-delta", delta: "plan", tabId });
- store.handleEvent({
- type: "reasoning-end",
- metadata: { anthropic: { signature: "wire-sig" } },
- tabId,
- });
-
- const chunks = getAssistantChunks(store);
- expect(chunks).toEqual([
- { type: "thinking", text: "plan", metadata: { anthropic: { signature: "wire-sig" } } },
- ]);
- });
-
- it("reasoning-delta after reasoning-end opens a new thinking chunk (v6 multi-block)", async () => {
- const { store, tabId } = await setupStoreWithTab();
-
- // First thinking block: delta → seal
- store.handleEvent({ type: "reasoning-delta", delta: "block-1", tabId });
- store.handleEvent({
- type: "reasoning-end",
- metadata: { anthropic: { signature: "sig-1" } },
- tabId,
- });
-
- // Second thinking block: new delta after seal must NOT extend the sealed chunk
- store.handleEvent({ type: "reasoning-delta", delta: "block-2", tabId });
-
- const chunks = getAssistantChunks(store);
- expect(chunks).toHaveLength(2);
- // First chunk sealed — text must not have been extended
- expect(chunks?.[0]).toEqual({
- type: "thinking",
- text: "block-1",
- metadata: { anthropic: { signature: "sig-1" } },
- });
- // Second chunk is fresh — no metadata yet
- expect(chunks?.[1]).toEqual({ type: "thinking", text: "block-2" });
- });
-
- it("reasoning-end without metadata is a no-op (subsequent delta still extends the chunk)", async () => {
- const { store, tabId } = await setupStoreWithTab();
-
- store.handleEvent({ type: "reasoning-delta", delta: "start", tabId });
- // reasoning-end with no metadata → helper returns early, chunk stays unsealed
- store.handleEvent({ type: "reasoning-end", tabId });
- // Next delta should extend the existing (still unsealed) chunk
- store.handleEvent({ type: "reasoning-delta", delta: " continued", tabId });
-
- const chunks = getAssistantChunks(store);
- expect(chunks).toHaveLength(1);
- expect(chunks?.[0]).toEqual({ type: "thinking", text: "start continued" });
- });
-
- it("interleaved think→text→think yields three chunks in order", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({ type: "reasoning-delta", delta: "thinking-1", tabId });
- store.handleEvent({ type: "text-delta", delta: "speaking-1", tabId });
- store.handleEvent({ type: "reasoning-delta", delta: "thinking-2", tabId });
- const chunks = getAssistantChunks(store);
- expect(chunks).toHaveLength(3);
- expect(chunks?.[0]?.type).toBe("thinking");
- expect(chunks?.[1]?.type).toBe("text");
- expect(chunks?.[2]?.type).toBe("thinking");
- });
-
- it("error event during a turn appends an error chunk to the in-flight message", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({ type: "text-delta", delta: "before", tabId });
- store.handleEvent({ type: "error", error: "something went wrong", tabId });
- const chunks = getAssistantChunks(store);
- expect(chunks).toHaveLength(2);
- expect(chunks?.[0]).toEqual({ type: "text", text: "before" });
- expect(chunks?.[1]?.type).toBe("error");
- if (chunks?.[1]?.type === "error") {
- expect(chunks[1].message).toBe("something went wrong");
- }
- expect(store.tabs[0]?.agentStatus).toBe("error");
- });
-
- it("error event with no in-flight turn opens a fresh assistant message", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({ type: "error", error: "boom", tabId });
- const assistantMessages =
- store.tabs[0]?.renderGroups.filter((m) => m.role === "assistant") ?? [];
- expect(assistantMessages.length).toBeGreaterThanOrEqual(1);
- const errChunks = assistantMessages[assistantMessages.length - 1]?.chunks;
- expect(errChunks).toHaveLength(1);
- expect(errChunks?.[0]?.type).toBe("error");
- if (errChunks?.[0]?.type === "error") {
- expect(errChunks[0].message).toBe("boom");
- }
- expect(store.tabs[0]?.agentStatus).toBe("error");
- });
-
- it("notice during a turn appends a system chunk on the assistant message", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({ type: "text-delta", delta: "hi", tabId });
- store.handleEvent({ type: "notice", message: "heads up", tabId });
- const chunks = getAssistantChunks(store);
- expect(chunks).toHaveLength(2);
- expect(chunks?.[1]?.type).toBe("system");
- if (chunks?.[1]?.type === "system") {
- expect(chunks[1].kind).toBe("notice");
- expect(chunks[1].text).toBe("heads up");
- }
- });
-
- it("notice with no turn in flight creates a role:system message", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({ type: "notice", message: "standalone", tabId });
- const systemMsg = store.tabs[0]?.renderGroups.find((m) => m.role === "system");
- expect(systemMsg).toBeDefined();
- const chunks = systemMsg?.chunks;
- expect(chunks).toHaveLength(1);
- if (chunks?.[0]?.type === "system") {
- expect(chunks[0].text).toBe("standalone");
- }
- });
-
- it("two notices with no turn coalesce onto the same system message as two chunks", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({ type: "notice", message: "first", tabId });
- store.handleEvent({ type: "notice", message: "second", tabId });
- const sysMsgs = store.tabs[0]?.renderGroups.filter((m) => m.role === "system") ?? [];
- expect(sysMsgs).toHaveLength(1);
- expect(sysMsgs[0]?.chunks).toHaveLength(2);
- });
-
- it("shell-output stdout/stderr append to the most recent tool-batch entry", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({
- type: "tool-call",
- toolCall: { id: "tc1", name: "run_shell", arguments: { command: "ls" } },
- tabId,
- });
- store.handleEvent({ type: "shell-output", data: "file1\n", stream: "stdout", tabId });
- store.handleEvent({ type: "shell-output", data: "file2\n", stream: "stdout", tabId });
- store.handleEvent({ type: "shell-output", data: "err line\n", stream: "stderr", tabId });
- const chunk = getAssistantChunks(store)?.[0];
- if (chunk?.type === "tool-batch") {
- expect(chunk.calls[0]?.shellOutput?.stdout).toBe("file1\nfile2\n");
- expect(chunk.calls[0]?.shellOutput?.stderr).toBe("err line\n");
- } else {
- expect.fail("Expected tool-batch chunk");
- }
- });
-});
-
-describe("tabStore — reactivity contract", () => {
- // These tests specifically exercise the structuredClone-vs-$state.snapshot
- // regression. If applyChunkEvent reverts to `structuredClone(m.chunks)`,
- // the call will throw DataCloneError on the Svelte reactive proxy, the
- // throw will surface (post-fix to ws.svelte.ts) or be swallowed (pre-fix),
- // and either way the chunks below will end up empty rather than
- // populated. These assertions ensure the bug class can't return silently.
-
- it("a streaming sequence produces non-empty chunks on a real $state-backed message", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({ type: "status", status: "running", tabId });
- store.handleEvent({ type: "reasoning-delta", delta: "I should...", tabId });
- store.handleEvent({
- type: "tool-call",
- toolCall: { id: "t1", name: "list_files", arguments: { path: "." } },
- tabId,
- });
- store.handleEvent({
- type: "tool-result",
- toolResult: { toolCallId: "t1", result: "files", isError: false },
- tabId,
- });
- store.handleEvent({ type: "text-delta", delta: "Result is...", tabId });
- store.handleEvent({ type: "status", status: "idle", tabId });
-
- const chunks = getAssistantChunks(store);
- // 3 chunks: thinking, tool-batch, text. Anything less means the
- // reactive-clone step swallowed events.
- expect(chunks).toHaveLength(3);
- expect(chunks?.[0]?.type).toBe("thinking");
- expect(chunks?.[1]?.type).toBe("tool-batch");
- expect(chunks?.[2]?.type).toBe("text");
- // And the chunks must NOT be reactive proxies — they must be
- // plain snapshot-cloned objects safe to serialize/pass around.
- // Probing via JSON round-trip is a cheap correctness check.
- expect(() => JSON.stringify(chunks)).not.toThrow();
- });
-
- it("multiple events on the same message accumulate (this is the chunks=0 regression)", async () => {
- const { store, tabId } = await setupStoreWithTab();
- // Drive a fairly large number of deltas. The original bug had every
- // content event after the first one silently failing — so this would
- // produce 1 chunk with 1 char (or 0 chunks if the first one also
- // failed for some reason). With the fix, the text accumulates.
- for (let i = 0; i < 50; i++) {
- store.handleEvent({ type: "text-delta", delta: `${i} `, tabId });
- }
- const chunks = getAssistantChunks(store);
- expect(chunks).toHaveLength(1);
- expect(chunks?.[0]?.type).toBe("text");
- if (chunks?.[0]?.type === "text") {
- // "0 1 2 ... 49 " is well over 100 chars.
- expect(chunks[0].text.length).toBeGreaterThan(100);
- }
- });
-
- it("statuses event resyncs a tab the frontend thought was running but the backend says is idle", async () => {
- const { store, tabId } = await setupStoreWithTab();
- // Drive the frontend into 'thinks it's running' state.
- store.handleEvent({ type: "status", status: "running", tabId });
- store.handleEvent({ type: "text-delta", delta: "starting", tabId });
- expect(store.tabs[0]?.agentStatus).toBe("running");
- expect(store.tabs[0]?.currentAssistantId).not.toBeNull();
-
- // Backend says: idle. (Simulates the WS-reconnect statuses snapshot
- // after the in-flight agent was lost — bun --watch restart, network
- // hiccup, etc.)
- store.handleEvent({
- type: "statuses",
- statuses: { [tabId]: { status: "idle" } },
- });
-
- expect(store.tabs[0]?.agentStatus).toBe("idle");
- expect(store.tabs[0]?.currentAssistantId).toBeNull();
- // The streaming flag on the in-flight message should be cleared.
- const assistant = store.tabs[0]?.renderGroups.find((m) => m.role === "assistant");
- expect(assistant?.isStreaming).toBe(false);
- });
-});
-
-describe("tabStore — permission flow", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
-
- it("permission-prompt sets pendingPermissions", async () => {
- const store = createTabStore();
- const prompt: PermissionPrompt = {
- id: "p1",
- permission: "bash",
- patterns: ["*"],
- always: ["*"],
- description: "Run a command",
- metadata: { command: "ls" },
- };
- store.handleEvent({ type: "permission-prompt", pending: [prompt] });
- expect(store.pendingPermissions).toHaveLength(1);
- expect(store.pendingPermissions[0]?.id).toBe("p1");
- });
-
- it("permission-prompt replaces previous pending permissions", async () => {
- const store = createTabStore();
- const p1: PermissionPrompt = {
- id: "p1",
- permission: "bash",
- patterns: [],
- always: [],
- description: "First",
- metadata: {},
- };
- const p2: PermissionPrompt = {
- id: "p2",
- permission: "read",
- patterns: [],
- always: [],
- description: "Second",
- metadata: {},
- };
- store.handleEvent({ type: "permission-prompt", pending: [p1] });
- store.handleEvent({ type: "permission-prompt", pending: [p2] });
- expect(store.pendingPermissions).toHaveLength(1);
- expect(store.pendingPermissions[0]?.id).toBe("p2");
- });
-
- it("replyPermission removes the permission and sends over WS", async () => {
- const store = createTabStore();
- const prompt: PermissionPrompt = {
- id: "p1",
- permission: "bash",
- patterns: [],
- always: [],
- description: "Run command",
- metadata: { command: "echo hi" },
- };
- store.handleEvent({ type: "permission-prompt", pending: [prompt] });
- store.replyPermission("p1", "once");
- expect(store.pendingPermissions).toHaveLength(0);
- expect(wsClient.send).toHaveBeenCalledWith({
- type: "permission-reply",
- id: "p1",
- reply: "once",
- });
- });
-
- it("replyPermission with 'always' sends correct payload", async () => {
- const store = createTabStore();
- const prompt: PermissionPrompt = {
- id: "p2",
- permission: "read",
- patterns: ["src/**"],
- always: ["src/**"],
- description: "Read a file",
- metadata: { filepath: "src/foo.ts" },
- };
- store.handleEvent({ type: "permission-prompt", pending: [prompt] });
- store.replyPermission("p2", "always");
- expect(wsClient.send).toHaveBeenCalledWith({
- type: "permission-reply",
- id: "p2",
- reply: "always",
- });
- expect(store.pendingPermissions).toHaveLength(0);
- });
-
- it("replyPermission with 'reject' removes the permission", async () => {
- const store = createTabStore();
- const prompt: PermissionPrompt = {
- id: "p3",
- permission: "edit",
- patterns: [],
- always: [],
- description: "Edit a file",
- metadata: {},
- };
- store.handleEvent({ type: "permission-prompt", pending: [prompt] });
- store.replyPermission("p3", "reject");
- expect(store.pendingPermissions).toHaveLength(0);
- expect(wsClient.send).toHaveBeenCalledWith({
- type: "permission-reply",
- id: "p3",
- reply: "reject",
- });
- });
-
- it("replyPermission adds an entry to permissionLog", async () => {
- const store = createTabStore();
- const prompt: PermissionPrompt = {
- id: "p1",
- permission: "bash",
- patterns: ["*"],
- always: ["*"],
- description: "Run a command",
- metadata: {},
- };
- store.handleEvent({ type: "permission-prompt", pending: [prompt] });
- store.replyPermission("p1", "once");
- expect(store.permissionLog).toHaveLength(1);
- expect(store.permissionLog[0]?.permission).toBe("bash");
- expect(store.permissionLog[0]?.action).toBe("once");
- expect(store.permissionLog[0]?.description).toBe("Run a command");
- });
-
- it("permissionLog accumulates multiple entries", async () => {
- const store = createTabStore();
- const p1: PermissionPrompt = {
- id: "p1",
- permission: "bash",
- patterns: [],
- always: [],
- description: "First",
- metadata: {},
- };
- const p2: PermissionPrompt = {
- id: "p2",
- permission: "read",
- patterns: [],
- always: [],
- description: "Second",
- metadata: {},
- };
- store.handleEvent({ type: "permission-prompt", pending: [p1, p2] });
- store.replyPermission("p1", "always");
- store.replyPermission("p2", "reject");
- expect(store.permissionLog).toHaveLength(2);
- expect(store.permissionLog[0]?.action).toBe("always");
- expect(store.permissionLog[1]?.action).toBe("reject");
- });
-
- it("replyPermission for unknown id does not add to log", async () => {
- const store = createTabStore();
- store.replyPermission("nonexistent", "once");
- expect(store.permissionLog).toHaveLength(0);
- });
-});
-
-// Shell output JSON parsing — a small helper that mirrors logic in
-// ToolCallDisplay.svelte. Kept here as a self-contained unit; the
-// component itself isn't tested in this file.
-function parseShellResult(
- result: string,
-): { stdout: string; stderr: string; exitCode: number } | null {
- try {
- const parsed = JSON.parse(result) as unknown;
- if (
- parsed !== null &&
- typeof parsed === "object" &&
- "stdout" in parsed &&
- "stderr" in parsed &&
- "exitCode" in parsed
- ) {
- const p = parsed as Record<string, unknown>;
- return {
- stdout: String(p.stdout ?? ""),
- stderr: String(p.stderr ?? ""),
- exitCode: Number(p.exitCode ?? 0),
- };
- }
- return null;
- } catch {
- return null;
- }
-}
-
-describe("shell output parsing helper", () => {
- it("parses a valid shell result JSON", () => {
- const result = JSON.stringify({ stdout: "hello\n", stderr: "", exitCode: 0 });
- const parsed = parseShellResult(result);
- expect(parsed).not.toBeNull();
- expect(parsed?.stdout).toBe("hello\n");
- expect(parsed?.stderr).toBe("");
- expect(parsed?.exitCode).toBe(0);
- });
-
- it("parses non-zero exit code and stderr", () => {
- const result = JSON.stringify({ stdout: "", stderr: "error: not found", exitCode: 1 });
- const parsed = parseShellResult(result);
- expect(parsed?.exitCode).toBe(1);
- expect(parsed?.stderr).toBe("error: not found");
- });
-
- it("returns null for invalid JSON", () => {
- expect(parseShellResult("not json")).toBeNull();
- });
-
- it("returns null for JSON that lacks required fields", () => {
- expect(parseShellResult(JSON.stringify({ stdout: "foo" }))).toBeNull();
- });
-
- it("returns null for non-object JSON", () => {
- expect(parseShellResult(JSON.stringify(42))).toBeNull();
- });
-});
-
-// ─── hydrateFromBackend ─────────────────────────────────────────
-//
-// Verifies the browser-reopen restore path: GET /tabs + GET /status +
-// GET /tabs/:id/messages combined into the in-memory tab store with
-// in-flight chunks seeded for any running tab.
-
-describe("hydrateFromBackend", () => {
- it("restores tabs from /tabs with their persisted messages", async () => {
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string) => {
- if (url.endsWith("/tabs")) {
- return Promise.resolve({
- ok: true,
- json: () =>
- Promise.resolve({
- tabs: [
- { id: "t1", title: "First", keyId: null, modelId: null, parentTabId: null },
- { id: "t2", title: "Second", keyId: "k", modelId: "m", parentTabId: null },
- ],
- }),
- });
- }
- if (url.endsWith("/status")) {
- return Promise.resolve({
- ok: true,
- json: () => Promise.resolve({ statuses: {} }),
- });
- }
- if (url.split("?")[0]?.endsWith("/tabs/t1/chunks")) {
- return Promise.resolve({
- ok: true,
- json: () =>
- Promise.resolve({
- chunks: [
- chunkRow("m1", "t1", 0, "u1", "user", "text", { text: "hello" }),
- chunkRow("m2", "t1", 1, "u1", "assistant", "text", { text: "hi back" }),
- ],
- total: 2,
- oldestSeq: 0,
- }),
- });
- }
- if (url.split("?")[0]?.endsWith("/tabs/t2/chunks")) {
- return Promise.resolve({
- ok: true,
- json: () => Promise.resolve({ chunks: [], total: 0, oldestSeq: null }),
- });
- }
- return Promise.reject(new Error(`unexpected fetch ${url}`));
- }),
- );
-
- const store = createTabStore();
- const n = await store.hydrateFromBackend();
- expect(n).toBe(2);
- expect(store.tabs.length).toBe(2);
- expect(store.tabs[0]?.id).toBe("t1");
- expect(store.tabs[0]?.renderGroups.length).toBe(2);
- expect(store.tabs[1]?.id).toBe("t2");
- expect(store.tabs[1]?.renderGroups.length).toBe(0);
- expect(store.activeTabId).toBe("t1");
- });
-
- it("seeds the in-flight assistant message from /status for a running tab", async () => {
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string) => {
- if (url.endsWith("/tabs")) {
- return Promise.resolve({
- ok: true,
- json: () =>
- Promise.resolve({
- tabs: [
- { id: "tr", title: "Running tab", keyId: null, modelId: null, parentTabId: null },
- ],
- }),
- });
- }
- if (url.endsWith("/status")) {
- return Promise.resolve({
- ok: true,
- json: () =>
- Promise.resolve({
- statuses: {
- tr: {
- status: "running",
- currentAssistantId: "live-msg-id",
- currentChunks: [
- { type: "thinking", text: "still thinking" },
- { type: "text", text: "partial " },
- ],
- },
- },
- }),
- });
- }
- if (url.split("?")[0]?.endsWith("/tabs/tr/chunks")) {
- return Promise.resolve({
- ok: true,
- json: () =>
- Promise.resolve({
- chunks: [chunkRow("u1", "tr", 0, "turn-r", "user", "text", { text: "go" })],
- total: 1,
- oldestSeq: 0,
- }),
- });
- }
- return Promise.reject(new Error(`unexpected fetch ${url}`));
- }),
- );
-
- const store = createTabStore();
- const n = await store.hydrateFromBackend();
- expect(n).toBe(1);
- const tab = store.tabs[0];
- expect(tab?.agentStatus).toBe("running");
- expect(tab?.currentAssistantId).toBe("live-msg-id");
- // Two messages: the user message + the seeded in-flight assistant.
- expect(tab?.renderGroups.length).toBe(2);
- const inflight = tab?.renderGroups.find((m) => m.id === "live-msg-id");
- expect(inflight).toBeDefined();
- expect(inflight?.isStreaming).toBe(true);
- expect(inflight?.chunks).toEqual([
- { type: "thinking", text: "still thinking" },
- { type: "text", text: "partial " },
- ]);
- });
-
- it("returns 0 and leaves tabs empty when /tabs fails", async () => {
- vi.stubGlobal(
- "fetch",
- vi.fn(() => Promise.resolve({ ok: false, json: () => Promise.resolve({}) })),
- );
- const store = createTabStore();
- const n = await store.hydrateFromBackend();
- expect(n).toBe(0);
- expect(store.tabs.length).toBe(0);
- });
-
- it("returns 0 and leaves tabs empty when /tabs returns an empty array", async () => {
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string) => {
- if (url.endsWith("/tabs")) {
- return Promise.resolve({ ok: true, json: () => Promise.resolve({ tabs: [] }) });
- }
- return Promise.reject(new Error(`unexpected fetch ${url}`));
- }),
- );
- const store = createTabStore();
- const n = await store.hydrateFromBackend();
- expect(n).toBe(0);
- expect(store.tabs.length).toBe(0);
- });
-
- it("is a no-op when the store already has tabs (idempotency)", async () => {
- const store = createTabStore();
- // Pretend the store already has a tab (e.g. from a hot-reload).
- // We do this by reaching into the store via the public API.
- // Use the create path with mocked fetch failure (existing
- // `createNewTab` already tolerates fetch failure — adds locally).
- // (beforeEach already stubs fetch to reject, so createNewTab will
- // proceed past the failed POST and add the tab locally.)
- await store.createNewTab();
- expect(store.tabs.length).toBe(1);
-
- // Now swap to a fetch that would lie about there being 3 tabs;
- // hydrateFromBackend must NOT call it. We use a fresh mock that
- // rejects to catch any stray background async calls too.
- let hydrateCallCount = 0;
- const sentinelFetch = vi.fn((url: string) => {
- // Allow background auto-agent/skill fetches that fire from
- // createNewTab's void async closure (autoSelectDefaultAgent,
- // autoCheckDefaultSkills) — they use /agents and /skills paths,
- // not /tabs. Reject them so they don't interfere.
- if (url.includes("/agents") || url.includes("/skills")) {
- return Promise.reject(new Error("test: background fetch ignored"));
- }
- // Any /tabs call would mean hydrateFromBackend ran — count it.
- hydrateCallCount++;
- return Promise.resolve({
- ok: true,
- json: () => Promise.resolve({ tabs: [{ id: "x" }, { id: "y" }, { id: "z" }] }),
- });
- });
- vi.stubGlobal("fetch", sentinelFetch);
- const n = await store.hydrateFromBackend();
- expect(n).toBe(0);
- expect(store.tabs.length).toBe(1);
- expect(hydrateCallCount).toBe(0);
- });
-
- it("restores a tab with an idle status when /status omits it", async () => {
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string) => {
- if (url.endsWith("/tabs")) {
- return Promise.resolve({
- ok: true,
- json: () =>
- Promise.resolve({
- tabs: [{ id: "ti", title: "Idle", keyId: null, modelId: null, parentTabId: null }],
- }),
- });
- }
- if (url.endsWith("/status")) {
- return Promise.resolve({ ok: true, json: () => Promise.resolve({ statuses: {} }) });
- }
- if (url.split("?")[0]?.endsWith("/tabs/ti/chunks")) {
- return Promise.resolve({
- ok: true,
- json: () => Promise.resolve({ chunks: [], total: 0, oldestSeq: null }),
- });
- }
- return Promise.reject(new Error(`unexpected fetch ${url}`));
- }),
- );
- const store = createTabStore();
- const n = await store.hydrateFromBackend();
- expect(n).toBe(1);
- expect(store.tabs[0]?.agentStatus).toBe("idle");
- expect(store.tabs[0]?.currentAssistantId).toBeNull();
- });
-
- it("restores a tab with empty messages when /tabs/:id/messages fails (per-tab failure isolation)", async () => {
- // The hydrateFromBackend implementation wraps each per-tab
- // messages fetch in a try/catch so one tab's failure can't
- // destroy the whole restore pass. This test covers BOTH failure
- // modes the try/catch protects against:
- // - response.ok === false (HTTP error like 500)
- // - the fetch rejects (network error)
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string) => {
- if (url.endsWith("/tabs")) {
- return Promise.resolve({
- ok: true,
- json: () =>
- Promise.resolve({
- tabs: [
- {
- id: "tA",
- title: "Healthy",
- keyId: null,
- modelId: null,
- parentTabId: null,
- },
- {
- id: "tB",
- title: "Broken (HTTP 500)",
- keyId: null,
- modelId: null,
- parentTabId: null,
- },
- {
- id: "tC",
- title: "Broken (network)",
- keyId: null,
- modelId: null,
- parentTabId: null,
- },
- ],
- }),
- });
- }
- if (url.endsWith("/status")) {
- return Promise.resolve({ ok: true, json: () => Promise.resolve({ statuses: {} }) });
- }
- if (url.split("?")[0]?.endsWith("/tabs/tA/chunks")) {
- return Promise.resolve({
- ok: true,
- json: () =>
- Promise.resolve({
- chunks: [chunkRow("msg-a", "tA", 0, "turn-a", "user", "text", { text: "ok" })],
- total: 1,
- oldestSeq: 0,
- }),
- });
- }
- if (url.split("?")[0]?.endsWith("/tabs/tB/chunks")) {
- // HTTP error path: response is not ok.
- return Promise.resolve({ ok: false, json: () => Promise.resolve({}) });
- }
- if (url.split("?")[0]?.endsWith("/tabs/tC/chunks")) {
- // Network error path: the fetch itself rejects.
- return Promise.reject(new Error("simulated network failure"));
- }
- return Promise.reject(new Error(`unexpected fetch ${url}`));
- }),
- );
-
- const store = createTabStore();
- const n = await store.hydrateFromBackend();
- expect(n).toBe(3);
-
- // Healthy tab restored with its message.
- const tA = store.tabs.find((t) => t.id === "tA");
- expect(tA?.renderGroups.length).toBe(1);
- expect(tA?.renderGroups[0]?.chunks).toEqual([{ type: "text", text: "ok" }]);
-
- // Both broken tabs restored with empty message lists — neither
- // crashed the hydration nor leaked an error chunk into the UI.
- const tB = store.tabs.find((t) => t.id === "tB");
- expect(tB).toBeDefined();
- expect(tB?.renderGroups.length).toBe(0);
- expect(tB?.agentStatus).toBe("idle");
-
- const tC = store.tabs.find((t) => t.id === "tC");
- expect(tC).toBeDefined();
- expect(tC?.renderGroups.length).toBe(0);
- expect(tC?.agentStatus).toBe("idle");
- });
-
- // ─── usage persistence: seed cacheStats from the backend aggregate ──
- it("seeds cacheStats from a tab's usageStats on hydrate (reload persistence)", async () => {
- const usageStats = {
- inputTokens: 2200,
- outputTokens: 100,
- cacheReadTokens: 1000,
- cacheWriteTokens: 1000,
- requests: 2,
- last: { inputTokens: 1200, outputTokens: 60, cacheReadTokens: 1000, cacheWriteTokens: 100 },
- };
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string) => {
- if (url.endsWith("/tabs")) {
- return Promise.resolve({
- ok: true,
- json: () =>
- Promise.resolve({
- tabs: [
- {
- id: "tu",
- title: "Has usage",
- keyId: null,
- modelId: null,
- parentTabId: null,
- usageStats,
- },
- {
- id: "tn",
- title: "No usage",
- keyId: null,
- modelId: null,
- parentTabId: null,
- usageStats: null,
- },
- ],
- }),
- });
- }
- if (url.endsWith("/status")) {
- return Promise.resolve({ ok: true, json: () => Promise.resolve({ statuses: {} }) });
- }
- if (url.split("?")[0]?.endsWith("/tabs/tu/chunks")) {
- return Promise.resolve({
- ok: true,
- json: () => Promise.resolve({ chunks: [], total: 0, oldestSeq: null }),
- });
- }
- if (url.split("?")[0]?.endsWith("/tabs/tn/chunks")) {
- return Promise.resolve({
- ok: true,
- json: () => Promise.resolve({ chunks: [], total: 0, oldestSeq: null }),
- });
- }
- return Promise.reject(new Error(`unexpected fetch ${url}`));
- }),
- );
-
- const store = createTabStore();
- const n = await store.hydrateFromBackend();
- expect(n).toBe(2);
- // Tab with persisted usage → cacheStats seeded directly from the aggregate.
- expect(store.tabs.find((t) => t.id === "tu")?.cacheStats).toEqual(usageStats);
- // Tab with null usageStats → cacheStats stays undefined (no usage yet).
- expect(store.tabs.find((t) => t.id === "tn")?.cacheStats).toBeUndefined();
- });
-
- it("does not re-seed or double-count cacheStats on a statuses reconnect after hydrate", async () => {
- const usageStats = {
- inputTokens: 1000,
- outputTokens: 40,
- cacheReadTokens: 0,
- cacheWriteTokens: 900,
- requests: 1,
- last: { inputTokens: 1000, outputTokens: 40, cacheReadTokens: 0, cacheWriteTokens: 900 },
- };
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string) => {
- if (url.endsWith("/tabs")) {
- return Promise.resolve({
- ok: true,
- json: () =>
- Promise.resolve({
- tabs: [
- {
- id: "tr",
- title: "Reconnect",
- keyId: null,
- modelId: null,
- parentTabId: null,
- usageStats,
- },
- ],
- }),
- });
- }
- if (url.endsWith("/status")) {
- return Promise.resolve({ ok: true, json: () => Promise.resolve({ statuses: {} }) });
- }
- if (url.split("?")[0]?.endsWith("/tabs/tr/chunks")) {
- return Promise.resolve({
- ok: true,
- json: () => Promise.resolve({ chunks: [], total: 0, oldestSeq: null }),
- });
- }
- return Promise.reject(new Error(`unexpected fetch ${url}`));
- }),
- );
-
- const store = createTabStore();
- await store.hydrateFromBackend();
- expect(store.tabs.find((t) => t.id === "tr")?.cacheStats).toEqual(usageStats);
-
- // A WS reconnect snapshot must NOT touch cacheStats (in-session live
- // `usage` events own the running totals; the aggregate seed is reload-only).
- store.handleEvent({ type: "statuses", statuses: { tr: { status: "idle" } } });
- expect(store.tabs.find((t) => t.id === "tr")?.cacheStats).toEqual(usageStats);
- });
-
- it("keeps accumulating live usage events after a hydrate seed", async () => {
- const usageStats = {
- inputTokens: 1000,
- outputTokens: 40,
- cacheReadTokens: 0,
- cacheWriteTokens: 900,
- requests: 1,
- last: { inputTokens: 1000, outputTokens: 40, cacheReadTokens: 0, cacheWriteTokens: 900 },
- };
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string) => {
- if (url.endsWith("/tabs")) {
- return Promise.resolve({
- ok: true,
- json: () =>
- Promise.resolve({
- tabs: [
- {
- id: "tl",
- title: "Live after hydrate",
- keyId: null,
- modelId: null,
- parentTabId: null,
- usageStats,
- },
- ],
- }),
- });
- }
- if (url.endsWith("/status")) {
- return Promise.resolve({ ok: true, json: () => Promise.resolve({ statuses: {} }) });
- }
- if (url.split("?")[0]?.endsWith("/tabs/tl/chunks")) {
- return Promise.resolve({
- ok: true,
- json: () => Promise.resolve({ chunks: [], total: 0, oldestSeq: null }),
- });
- }
- return Promise.reject(new Error(`unexpected fetch ${url}`));
- }),
- );
-
- const store = createTabStore();
- await store.hydrateFromBackend();
-
- // A new in-session usage event folds ON TOP of the seeded aggregate.
- store.handleEvent({
- type: "usage",
- tabId: "tl",
- usage: { inputTokens: 200, outputTokens: 10, cacheReadTokens: 150, cacheWriteTokens: 0 },
- });
- const stats = store.tabs.find((t) => t.id === "tl")?.cacheStats;
- expect(stats?.requests).toBe(2);
- expect(stats?.inputTokens).toBe(1200);
- expect(stats?.outputTokens).toBe(50);
- expect(stats?.cacheReadTokens).toBe(150);
- expect(stats?.cacheWriteTokens).toBe(900);
- expect(stats?.last).toEqual({
- inputTokens: 200,
- outputTokens: 10,
- cacheReadTokens: 150,
- cacheWriteTokens: 0,
- });
- });
-
- // Cross-feature contract (Context Window view, branch u2): the panel derives
- // current context size from `cacheStats.last` via computeContextUsage. This
- // test proves persistence restores that field on hydrate, so the view shows a
- // real "x / max" immediately after a reload on a NEW DEVICE — not "No context
- // data yet". Guards the contract so neither side can silently break it.
- it("restores cacheStats.last on hydrate so the Context Window view has data after reload", async () => {
- const usageStats = {
- inputTokens: 90000,
- outputTokens: 3000,
- cacheReadTokens: 40000,
- cacheWriteTokens: 5000,
- requests: 3,
- // Most recent request's snapshot — the numerator the view reads.
- last: { inputTokens: 47000, outputTokens: 1200, cacheReadTokens: 30000, cacheWriteTokens: 0 },
- };
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string) => {
- if (url.endsWith("/tabs")) {
- return Promise.resolve({
- ok: true,
- json: () =>
- Promise.resolve({
- tabs: [
- {
- id: "tc",
- title: "Context after reload",
- keyId: null,
- modelId: null,
- parentTabId: null,
- usageStats,
- },
- ],
- }),
- });
- }
- if (url.endsWith("/status")) {
- return Promise.resolve({ ok: true, json: () => Promise.resolve({ statuses: {} }) });
- }
- if (url.split("?")[0]?.endsWith("/tabs/tc/chunks")) {
- return Promise.resolve({
- ok: true,
- json: () => Promise.resolve({ chunks: [], total: 0, oldestSeq: null }),
- });
- }
- return Promise.reject(new Error(`unexpected fetch ${url}`));
- }),
- );
-
- const store = createTabStore();
- await store.hydrateFromBackend();
-
- // What App.svelte passes into ContextWindowPanel: the active tab's cacheStats
- // plus the model's max (re-resolved from models.dev on load — here 200k).
- const cacheStats = store.tabs.find((t) => t.id === "tc")?.cacheStats ?? null;
- expect(cacheStats?.last).not.toBeNull();
-
- const usage = computeContextUsage(cacheStats, 200000);
- // current = last.inputTokens + last.outputTokens (47000 + 1200), NOT the
- // cumulative session totals (which would double-count history).
- expect(usage.current).toBe(48200);
- expect(usage.max).toBe(200000);
- expect(usage.percent).toBeCloseTo(24.1, 5);
- });
-});
-
-// ─── statuses WS event with the wider TabStatusSnapshot shape ───
-//
-// The handler must reconcile snapshot.status against the local tab,
-// and (when running) seed currentChunks into the in-flight assistant
-// message.
-
-describe("handleEvent statuses with TabStatusSnapshot", () => {
- it("seeds the in-flight assistant message when a running snapshot arrives", async () => {
- const store = createTabStore();
- // Manually add a tab to the store via the existing createNewTab path
- // (fetch was mocked to reject in beforeEach; createNewTab tolerates).
- // We then drive a statuses event.
- await store.createNewTab();
- const tabId = store.tabs[0]?.id;
- if (!tabId) throw new Error("test fixture: tab id missing");
-
- store.handleEvent({
- type: "statuses",
- statuses: {
- [tabId]: {
- status: "running",
- currentAssistantId: "live-x",
- currentChunks: [{ type: "text", text: "live data" }],
- },
- },
- });
-
- const tab = store.tabs.find((t) => t.id === tabId);
- expect(tab?.agentStatus).toBe("running");
- expect(tab?.currentAssistantId).toBe("live-x");
- const inflight = tab?.renderGroups.find((m) => m.id === "live-x");
- expect(inflight).toBeDefined();
- expect(inflight?.chunks).toEqual([{ type: "text", text: "live data" }]);
- expect(inflight?.isStreaming).toBe(true);
- });
-
- it("clears in-flight pointers when snapshot says the tab is idle", async () => {
- const store = createTabStore();
- await store.createNewTab();
- const tabId = store.tabs[0]?.id;
- if (!tabId) throw new Error("test fixture: tab id missing");
-
- // First put the tab into a running state with an in-flight message.
- store.handleEvent({
- type: "statuses",
- statuses: {
- [tabId]: {
- status: "running",
- currentAssistantId: "msg-a",
- currentChunks: [{ type: "text", text: "x" }],
- },
- },
- });
- expect(store.tabs.find((t) => t.id === tabId)?.currentAssistantId).toBe("msg-a");
-
- // Now snapshot says idle.
- store.handleEvent({
- type: "statuses",
- statuses: { [tabId]: { status: "idle" } },
- });
- const tab = store.tabs.find((t) => t.id === tabId);
- expect(tab?.agentStatus).toBe("idle");
- expect(tab?.currentAssistantId).toBeNull();
- const msgA = tab?.renderGroups.find((m) => m.id === "msg-a");
- expect(msgA?.isStreaming).toBe(false);
- });
-});
-
-describe("tabStore — cache rate (usage events)", () => {
- it("accumulates usage events into per-tab cacheStats and tracks the last request", async () => {
- const { store, tabId } = await setupStoreWithTab();
-
- // No usage yet.
- expect(store.tabs.find((t) => t.id === tabId)?.cacheStats).toBeUndefined();
-
- // First request: mostly a cache write (cold prefix).
- store.handleEvent({
- type: "usage",
- tabId,
- usage: { inputTokens: 1000, outputTokens: 40, cacheReadTokens: 0, cacheWriteTokens: 900 },
- });
- // Second request: mostly a cache hit.
- store.handleEvent({
- type: "usage",
- tabId,
- usage: { inputTokens: 1200, outputTokens: 60, cacheReadTokens: 1000, cacheWriteTokens: 100 },
- });
-
- const stats = store.tabs.find((t) => t.id === tabId)?.cacheStats;
- expect(stats).toBeDefined();
- expect(stats?.requests).toBe(2);
- // Cumulative totals.
- expect(stats?.inputTokens).toBe(2200);
- expect(stats?.outputTokens).toBe(100);
- expect(stats?.cacheReadTokens).toBe(1000);
- expect(stats?.cacheWriteTokens).toBe(1000);
- // `last` reflects only the most recent request.
- expect(stats?.last).toEqual({
- inputTokens: 1200,
- outputTokens: 60,
- cacheReadTokens: 1000,
- cacheWriteTokens: 100,
- });
- });
-
- it("ignores usage events with no tabId", async () => {
- const { store } = await setupStoreWithTab();
- store.handleEvent({
- type: "usage",
- usage: { inputTokens: 10, outputTokens: 1, cacheReadTokens: 5, cacheWriteTokens: 0 },
- });
- expect(store.tabs[0]?.cacheStats).toBeUndefined();
- });
-
- it("turn-sealed REPLACES cacheStats with the carried DB aggregate (reconcile to truth)", async () => {
- const { store, tabId } = await setupStoreWithTab();
- // Live events accumulate during the turn.
- store.handleEvent({
- type: "usage",
- tabId,
- usage: { inputTokens: 1000, outputTokens: 40, cacheReadTokens: 0, cacheWriteTokens: 900 },
- });
- expect(store.tabs.find((t) => t.id === tabId)?.cacheStats?.inputTokens).toBe(1000);
-
- // turn-sealed carries the authoritative aggregate → cacheStats is REPLACED.
- const aggregate = {
- inputTokens: 1000,
- outputTokens: 40,
- cacheReadTokens: 0,
- cacheWriteTokens: 900,
- requests: 1,
- last: { inputTokens: 1000, outputTokens: 40, cacheReadTokens: 0, cacheWriteTokens: 900 },
- };
- store.handleEvent({ type: "turn-sealed", turnId: "t1", tabId, usageStats: aggregate });
- expect(store.tabs.find((t) => t.id === tabId)?.cacheStats).toEqual(aggregate);
- });
-
- it("turn-sealed self-heals a live overshoot from a discarded fallback attempt", async () => {
- const { store, tabId } = await setupStoreWithTab();
- // Attempt 1 streamed usage live (overshoot), then rate-limited & discarded
- // server-side; attempt 2's usage also streamed live. Live = sum of BOTH.
- store.handleEvent({
- type: "usage",
- tabId,
- usage: { inputTokens: 999, outputTokens: 9, cacheReadTokens: 0, cacheWriteTokens: 0 },
- });
- store.handleEvent({
- type: "usage",
- tabId,
- usage: { inputTokens: 222, outputTokens: 22, cacheReadTokens: 100, cacheWriteTokens: 5 },
- });
- const overshoot = store.tabs.find((t) => t.id === tabId)?.cacheStats;
- expect(overshoot?.requests).toBe(2);
- expect(overshoot?.inputTokens).toBe(1221); // inflated: includes discarded attempt
-
- // The DB only persisted attempt 2 (the survivor). turn-sealed reconciles.
- const persisted = {
- inputTokens: 222,
- outputTokens: 22,
- cacheReadTokens: 100,
- cacheWriteTokens: 5,
- requests: 1,
- last: { inputTokens: 222, outputTokens: 22, cacheReadTokens: 100, cacheWriteTokens: 5 },
- };
- store.handleEvent({ type: "turn-sealed", turnId: "t1", tabId, usageStats: persisted });
- // Overshoot healed: cacheStats now matches the DB truth exactly.
- expect(store.tabs.find((t) => t.id === tabId)?.cacheStats).toEqual(persisted);
- });
-
- it("turn-sealed without usageStats leaves cacheStats untouched (back-compat)", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({
- type: "usage",
- tabId,
- usage: { inputTokens: 500, outputTokens: 5, cacheReadTokens: 0, cacheWriteTokens: 0 },
- });
- const before = store.tabs.find((t) => t.id === tabId)?.cacheStats;
- // Older backend: turn-sealed carries no usageStats field.
- store.handleEvent({ type: "turn-sealed", turnId: "t1", tabId });
- expect(store.tabs.find((t) => t.id === tabId)?.cacheStats).toEqual(before);
- });
-
- it("turn-sealed with usageStats: null clears cacheStats", async () => {
- const { store, tabId } = await setupStoreWithTab();
- store.handleEvent({
- type: "usage",
- tabId,
- usage: { inputTokens: 500, outputTokens: 5, cacheReadTokens: 0, cacheWriteTokens: 0 },
- });
- expect(store.tabs.find((t) => t.id === tabId)?.cacheStats).toBeDefined();
- // A null aggregate (no persisted usage rows) explicitly clears live stats.
- store.handleEvent({ type: "turn-sealed", turnId: "t1", tabId, usageStats: null });
- expect(store.tabs.find((t) => t.id === tabId)?.cacheStats).toBeUndefined();
- });
-});
-
-// ─── chunk-native store: eviction, pagination, reconcile ────────
-//
-// The store's source of truth for HISTORY is a flat ChunkRow[] (`tab.chunks`,
-// real seqs); the live turn is a transient tail (`tab.live`) folded from
-// stream deltas and reconciled into `chunks` on `turn-sealed`. `tab.renderGroups`
-// is a derived render projection. These tests drive the real store.
-
-describe("tabStore — chunk-native eviction / pagination / reconcile", () => {
- function chunksResponse(rows: Array<Record<string, unknown>>, total?: number) {
- const oldestSeq = rows.length > 0 ? ((rows[0]?.seq as number) ?? null) : null;
- return {
- ok: true,
- json: () => Promise.resolve({ chunks: rows, total: total ?? rows.length, oldestSeq }),
- };
- }
- function tabsListResponse(id: string) {
- return {
- ok: true,
- json: () => Promise.resolve({ tabs: [{ id, title: id, parentTabId: null }] }),
- };
- }
- function emptyStatuses() {
- return { ok: true, json: () => Promise.resolve({ statuses: {} }) };
- }
- const tick = () => new Promise((r) => setTimeout(r, 0));
-
- it("evicts sealed chunks to chunkLimit, trimming WITHIN one large turn", async () => {
- appSettings.chunkLimit = 5;
- try {
- // One turn of 10 chunk rows (the pathological single big turn).
- const rows = Array.from({ length: 10 }, (_, i) =>
- chunkRow(`c${i}`, "big", i, "turn-1", i === 0 ? "user" : "assistant", "text", {
- text: `t${i}`,
- }),
- );
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string) => {
- if (url.endsWith("/tabs")) return Promise.resolve(tabsListResponse("big"));
- if (url.endsWith("/status")) return Promise.resolve(emptyStatuses());
- if (url.split("?")[0]?.endsWith("/tabs/big/chunks"))
- return Promise.resolve(chunksResponse(rows, 10));
- return Promise.reject(new Error(`unexpected ${url}`));
- }),
- );
- const store = createTabStore();
- await store.hydrateFromBackend();
- const tab = store.tabs.find((t) => t.id === "big");
- // Bounded to chunkLimit; the OLDEST rows of the single turn were trimmed.
- expect(tab?.chunks.length).toBe(5);
- expect(tab?.chunks.map((c) => c.seq)).toEqual([5, 6, 7, 8, 9]);
- // Pagination cursor points at a real remaining seq.
- expect(tab?.oldestLoadedSeq).toBe(5);
- } finally {
- appSettings.chunkLimit = 100;
- }
- });
-
- it("loadOlderChunks prepends an older page and dedupes by seq", async () => {
- appSettings.chunkLimit = 1000; // don't evict during this test
- try {
- const newer = [6, 7, 8, 9].map((s) =>
- chunkRow(`c${s}`, "pg", s, "t", "assistant", "text", { text: `t${s}` }),
- );
- // Overlaps the newer window at seq 6 — must dedupe.
- const older = [2, 3, 4, 5, 6].map((s) =>
- chunkRow(`c${s}`, "pg", s, "t", "assistant", "text", { text: `t${s}` }),
- );
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string) => {
- if (url.endsWith("/tabs")) return Promise.resolve(tabsListResponse("pg"));
- if (url.endsWith("/status")) return Promise.resolve(emptyStatuses());
- if (url.split("?")[0]?.endsWith("/tabs/pg/chunks")) {
- return Promise.resolve(
- url.includes("before=") ? chunksResponse(older, 8) : chunksResponse(newer, 8),
- );
- }
- return Promise.reject(new Error(`unexpected ${url}`));
- }),
- );
- const store = createTabStore();
- await store.hydrateFromBackend();
- let tab = store.tabs.find((t) => t.id === "pg");
- expect(tab?.chunks.map((c) => c.seq)).toEqual([6, 7, 8, 9]);
- expect(tab?.oldestLoadedSeq).toBe(6);
-
- await store.loadOlderChunks("pg");
- tab = store.tabs.find((t) => t.id === "pg");
- expect(tab?.chunks.map((c) => c.seq)).toEqual([2, 3, 4, 5, 6, 7, 8, 9]);
- expect(tab?.oldestLoadedSeq).toBe(2);
- } finally {
- appSettings.chunkLimit = 100;
- }
- });
-
- it("turn-sealed folds the live turn into the sealed chunk log with real seqs", async () => {
- const sealed = [
- chunkRow("u", "rc", 0, "turn-x", "user", "text", { text: "hi" }),
- chunkRow("a", "rc", 1, "turn-x", "assistant", "text", { text: "answer" }),
- ];
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string) => {
- if (url.split("?")[0]?.endsWith("/tabs/rc/chunks"))
- return Promise.resolve(chunksResponse(sealed, 2));
- return Promise.reject(new Error(`unexpected ${url}`));
- }),
- );
- const store = createTabStore();
- store.handleEvent({
- type: "tab-created",
- id: "rc",
- title: "RC",
- keyId: null,
- modelId: null,
- parentTabId: null,
- });
- store.handleEvent({ type: "turn-start", turnId: "turn-x", tabId: "rc" });
- store.handleEvent({ type: "text-delta", delta: "answer", tabId: "rc" });
- // While streaming: live tail holds the in-flight assistant; sealed empty.
- let tab = store.tabs.find((t) => t.id === "rc");
- expect(tab?.live.length).toBe(1);
- expect(tab?.chunks.length).toBe(0);
- // The live assistant is tagged with the turn id (stable render key basis).
- expect(tab?.live[0]?.turnId).toBe("turn-x");
-
- store.handleEvent({ type: "turn-sealed", turnId: "turn-x", tabId: "rc" });
- await tick(); // reconcile refetch is async
- tab = store.tabs.find((t) => t.id === "rc");
- expect(tab?.live.length).toBe(0);
- expect(tab?.chunks.map((c) => c.seq)).toEqual([0, 1]);
- expect(tab?.renderGroups.map((m) => m.role)).toEqual(["user", "assistant"]);
- // The sealed messages carry the SAME turn id as the live ones did, so the
- // turn-scoped render key is stable across reconcile (no remount/flash).
- expect(tab?.renderGroups.every((m) => m.turnId === "turn-x")).toBe(true);
- });
-
- it("defers reconcile while scrolled up, then runs it on return to bottom", async () => {
- const sealed = [chunkRow("u", "df", 0, "turn-y", "user", "text", { text: "q" })];
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string) => {
- if (url.split("?")[0]?.endsWith("/tabs/df/chunks"))
- return Promise.resolve(chunksResponse(sealed, 1));
- return Promise.reject(new Error(`unexpected ${url}`));
- }),
- );
- const store = createTabStore();
- store.handleEvent({
- type: "tab-created",
- id: "df",
- title: "DF",
- keyId: null,
- modelId: null,
- parentTabId: null,
- });
- store.handleEvent({ type: "turn-start", turnId: "turn-y", tabId: "df" });
- store.handleEvent({ type: "text-delta", delta: "partial", tabId: "df" });
- store.setScrolledUp("df", true);
- store.handleEvent({ type: "turn-sealed", turnId: "turn-y", tabId: "df" });
- await tick();
- // Deferred: live tail still present, not yet folded into sealed chunks.
- expect(store.tabs.find((t) => t.id === "df")?.live.length).toBe(1);
-
- store.setScrolledUp("df", false);
- await tick();
- const tab = store.tabs.find((t) => t.id === "df");
- expect(tab?.live.length).toBe(0);
- expect(tab?.chunks.length).toBe(1);
- });
-
- it("preserves an optimistic queued user message when an earlier turn reconciles", async () => {
- const sealed = [
- chunkRow("u", "q", 0, "turn-a", "user", "text", { text: "first" }),
- chunkRow("a", "q", 1, "turn-a", "assistant", "text", { text: "answer" }),
- ];
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string) => {
- if (url.split("?")[0]?.endsWith("/tabs/q/chunks"))
- return Promise.resolve(chunksResponse(sealed, 2));
- return Promise.reject(new Error(`unexpected ${url}`));
- }),
- );
- const store = createTabStore();
- store.handleEvent({
- type: "tab-created",
- id: "q",
- title: "Q",
- keyId: null,
- modelId: null,
- parentTabId: null,
- });
- store.handleEvent({ type: "turn-start", turnId: "turn-a", tabId: "q" });
- store.handleEvent({ type: "text-delta", delta: "answer", tabId: "q" });
- // User queues a follow-up WHILE turn-a streams (optimistic, no turn id yet).
- store.handleEvent({
- type: "message-queued",
- tabId: "q",
- messageId: "q1",
- message: "do this next",
- });
- expect(store.tabs.find((t) => t.id === "q")?.live.some((m) => m.id === "queued-q1")).toBe(true);
-
- // turn-a seals → reconcile. The queued user bubble must survive.
- store.handleEvent({ type: "turn-sealed", turnId: "turn-a", tabId: "q" });
- await tick();
- const tab = store.tabs.find((t) => t.id === "q");
- expect(tab?.chunks.map((c) => c.seq)).toEqual([0, 1]);
- expect(tab?.live.some((m) => m.id === "queued-q1")).toBe(true);
- // The sealed turn's assistant was folded out of the live tail.
- expect(tab?.live.some((m) => m.role === "assistant")).toBe(false);
- });
-
- it("turn-start backfill skips a pending queued row (race), tags only the initiator", async () => {
- // Realistic race: the user sends a prompt (plain optimistic row, no turn id
- // yet) and, before the WS `turn-start` arrives, sends a follow-up that the
- // backend queues (`queued-` prefix). When `turn-start` finally lands, the
- // backfill must tag ONLY the plain initiator and SKIP the pending queued
- // row — otherwise the queued row inherits the sealing turn's id and is
- // wiped on reconcile (the Pass-2 Blocker).
- const store = createTabStore();
- store.handleEvent({
- type: "tab-created",
- id: "rq",
- title: "RQ",
- keyId: null,
- modelId: null,
- parentTabId: null,
- });
- store.switchTab("rq"); // sendMessage targets the active tab
- const sealed = [
- chunkRow("u", "rq", 0, "turn-a", "user", "text", { text: "first" }),
- chunkRow("a", "rq", 1, "turn-a", "assistant", "text", { text: "answer" }),
- ];
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string, opts?: { body?: string }) => {
- const path = url.split("?")[0] ?? "";
- if (path.endsWith("/tabs/rq/chunks")) return Promise.resolve(chunksResponse(sealed, 2));
- if (path.endsWith("/chat")) {
- // The 2nd send carries a queueId → backend reports it queued.
- const queued = (opts?.body ?? "").includes("queueId");
- return Promise.resolve({
- ok: true,
- json: () =>
- Promise.resolve(queued ? { status: "queued", messageId: "srv" } : { status: "ok" }),
- });
- }
- return Promise.resolve({
- ok: true,
- json: () => Promise.resolve({}),
- text: () => Promise.resolve(""),
- });
- }),
- );
-
- // A freshly created tab defaults to "running"; the agent is idle here.
- store.handleEvent({ type: "status", status: "idle", tabId: "rq" });
- await store.sendMessage("first"); // idle → plain optimistic user row
- store.handleEvent({ type: "status", status: "running", tabId: "rq" });
- await store.sendMessage("second"); // running → queued (queued- prefix)
-
- let tab = store.tabs.find((t) => t.id === "rq");
- const initiatorId = tab?.live.find((m) => m.role === "user" && !m.id.startsWith("queued-"))?.id;
- const queuedId = tab?.live.find((m) => m.id.startsWith("queued-"))?.id;
- expect(initiatorId).toBeTruthy();
- expect(queuedId).toBeTruthy();
-
- // turn-start arrives LATE: the queued follow-up is already in the live tail.
- store.handleEvent({ type: "turn-start", turnId: "turn-a", tabId: "rq" });
- tab = store.tabs.find((t) => t.id === "rq");
- expect(tab?.live.find((m) => m.id === initiatorId)?.turnId).toBe("turn-a");
- expect(tab?.live.find((m) => m.id === queuedId)?.turnId).toBeUndefined();
-
- store.handleEvent({ type: "text-delta", delta: "answer", tabId: "rq" });
- store.handleEvent({ type: "turn-sealed", turnId: "turn-a", tabId: "rq" });
- await tick();
- tab = store.tabs.find((t) => t.id === "rq");
- // Initiator folded cleanly into its sealed row (no duplicate user bubble);
- // the queued follow-up survives, still untagged and still queued.
- expect(tab?.chunks.map((c) => c.seq)).toEqual([0, 1]);
- expect(tab?.live.find((m) => m.id === queuedId)?.turnId).toBeUndefined();
- expect(tab?.live.some((m) => m.role === "assistant")).toBe(false);
- expect(tab?.renderGroups.map((m) => m.role)).toEqual(["user", "assistant", "user"]);
- });
-
- it("a consumed interrupt message collapses into the sealed turn (no lingering bubble)", async () => {
- // During a running turn the user queues a message; the agent CONSUMES it
- // (interrupt), folding its text into the turn's persisted chunks. The
- // frontend's consumed user bubble must be BOUND to the in-flight turn so it
- // is dropped on reconcile — otherwise it lingers untagged forever AND
- // duplicates the interrupt text now living in the sealed chunk (Pass-3 Block).
- const sealed = [
- chunkRow("u", "ic", 0, "turn-a", "user", "text", { text: "do a thing" }),
- chunkRow("a", "ic", 1, "turn-a", "assistant", "text", { text: "working ...resumed" }),
- ];
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string) => {
- if (url.split("?")[0]?.endsWith("/tabs/ic/chunks"))
- return Promise.resolve(chunksResponse(sealed, 2));
- return Promise.reject(new Error(`unexpected ${url}`));
- }),
- );
- const store = createTabStore();
- store.handleEvent({
- type: "tab-created",
- id: "ic",
- title: "IC",
- keyId: null,
- modelId: null,
- parentTabId: null,
- });
- store.handleEvent({ type: "turn-start", turnId: "turn-a", tabId: "ic" });
- store.handleEvent({ type: "text-delta", delta: "working", tabId: "ic" });
- store.handleEvent({ type: "message-queued", tabId: "ic", messageId: "qx", message: "stop" });
- // Agent consumes the queued message mid-turn (interrupt injection).
- store.handleEvent({ type: "message-consumed", tabId: "ic", messageIds: ["qx"] });
- let tab = store.tabs.find((t) => t.id === "ic");
- const consumed = tab?.live.find((m) => m.id === "qx");
- expect(consumed).toBeTruthy();
- // The fix: the consumed row is bound to the active turn, not left untagged.
- expect(consumed?.turnId).toBe("turn-a");
- expect(tab?.queuedMessages.some((m) => m.id === "qx")).toBe(false);
-
- store.handleEvent({ type: "text-delta", delta: "resumed", tabId: "ic" });
- store.handleEvent({ type: "turn-sealed", turnId: "turn-a", tabId: "ic" });
- await tick();
- tab = store.tabs.find((t) => t.id === "ic");
- // Collapsed to the persisted shape: the consumed bubble was dropped; only
- // the sealed chunks remain (no lingering / duplicated interrupt bubble).
- expect(tab?.live.length).toBe(0);
- expect(tab?.live.some((m) => m.id === "qx")).toBe(false);
- expect(tab?.chunks.map((c) => c.seq)).toEqual([0, 1]);
- });
-
- it("a continuation-consumed queued message becomes the next turn's initiator", async () => {
- // The turn-end fix: a message queued during turn-a is drained AFTER the
- // turn ends (reason: "continuation") to START turn-b. Its optimistic
- // `queued-` bubble must collapse into a single UNTAGGED user row so the
- // imminent turn-b `turn-start` tags it as that turn's initiator — and it
- // then folds cleanly into turn-b's sealed chunks (no linger, no dup).
- const sealedB = [
- chunkRow("ua", "cc", 0, "turn-a", "user", "text", { text: "first" }),
- chunkRow("aa", "cc", 1, "turn-a", "assistant", "text", { text: "first answer" }),
- chunkRow("ub", "cc", 2, "turn-b", "user", "text", { text: "next please" }),
- chunkRow("ab", "cc", 3, "turn-b", "assistant", "text", { text: "second answer" }),
- ];
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string) => {
- if (url.split("?")[0]?.endsWith("/tabs/cc/chunks"))
- return Promise.resolve(chunksResponse(sealedB, 4));
- return Promise.reject(new Error(`unexpected ${url}`));
- }),
- );
- const store = createTabStore();
- store.handleEvent({
- type: "tab-created",
- id: "cc",
- title: "CC",
- keyId: null,
- modelId: null,
- parentTabId: null,
- });
- // Turn A streams; user queues a follow-up while it runs.
- store.handleEvent({ type: "turn-start", turnId: "turn-a", tabId: "cc" });
- store.handleEvent({ type: "text-delta", delta: "first answer", tabId: "cc" });
- store.handleEvent({
- type: "message-queued",
- tabId: "cc",
- messageId: "q1",
- message: "next please",
- });
- let tab = store.tabs.find((t) => t.id === "cc");
- expect(tab?.live.some((m) => m.id === "queued-q1")).toBe(true);
-
- // Turn A ends. The backend drains the queue as a CONTINUATION (not an
- // interrupt) and emits message-consumed{reason:"continuation"}.
- store.handleEvent({
- type: "message-consumed",
- tabId: "cc",
- messageIds: ["q1"],
- reason: "continuation",
- });
- tab = store.tabs.find((t) => t.id === "cc");
- // The queued- bubble collapsed into ONE plain (untagged, un-prefixed) user row.
- expect(tab?.live.some((m) => m.id === "queued-q1")).toBe(false);
- const initiator = tab?.live.find((m) => m.role === "user");
- expect(initiator).toBeTruthy();
- expect(initiator?.id.startsWith("queued-")).toBe(false);
- expect(initiator?.turnId).toBeUndefined();
- expect(tab?.queuedMessages.some((m) => m.id === "q1")).toBe(false);
-
- // turn-a seals first (it was the running turn when the queue drained).
- store.handleEvent({ type: "turn-sealed", turnId: "turn-a", tabId: "cc" });
- // Now turn-b starts — it must tag the collapsed initiator row.
- store.handleEvent({ type: "turn-start", turnId: "turn-b", tabId: "cc" });
- tab = store.tabs.find((t) => t.id === "cc");
- const taggedInitiator = tab?.live.find((m) => m.role === "user" && m.turnId === "turn-b");
- expect(taggedInitiator).toBeTruthy();
-
- store.handleEvent({ type: "text-delta", delta: "second answer", tabId: "cc" });
- store.handleEvent({ type: "turn-sealed", turnId: "turn-b", tabId: "cc" });
- await tick();
- tab = store.tabs.find((t) => t.id === "cc");
- // Both turns are durable; the live tail is empty (initiator folded into
- // turn-b, no lingering/duplicated user bubble).
- expect(tab?.chunks.map((c) => c.seq)).toEqual([0, 1, 2, 3]);
- expect(tab?.live.length).toBe(0);
- expect(tab?.renderGroups.map((m) => m.role)).toEqual([
- "user",
- "assistant",
- "user",
- "assistant",
- ]);
- });
-
- it("collapses MULTIPLE continuation-consumed queued messages into one initiator row", async () => {
- // The backend joins several drained queued messages into a SINGLE user
- // turn (with a "\n---\n" separator). The frontend must mirror that: N
- // optimistic `queued-` bubbles collapse into exactly ONE untagged user
- // row carrying the joined text, which the next turn-start then tags.
- const store = createTabStore();
- store.handleEvent({
- type: "tab-created",
- id: "mc",
- title: "MC",
- keyId: null,
- modelId: null,
- parentTabId: null,
- });
- store.handleEvent({ type: "turn-start", turnId: "turn-a", tabId: "mc" });
- store.handleEvent({ type: "text-delta", delta: "answer", tabId: "mc" });
- // Two follow-ups queued while turn-a streams.
- store.handleEvent({ type: "message-queued", tabId: "mc", messageId: "q1", message: "first" });
- store.handleEvent({ type: "message-queued", tabId: "mc", messageId: "q2", message: "second" });
- let tab = store.tabs.find((t) => t.id === "mc");
- expect(tab?.live.filter((m) => m.id.startsWith("queued-")).length).toBe(2);
-
- // Turn ends; backend drains BOTH as one continuation.
- store.handleEvent({
- type: "message-consumed",
- tabId: "mc",
- messageIds: ["q1", "q2"],
- reason: "continuation",
- });
- tab = store.tabs.find((t) => t.id === "mc");
- // Exactly one user row, untagged, with the joined text — no queued- bubbles left.
- const userRows = tab?.live.filter((m) => m.role === "user") ?? [];
- expect(userRows.length).toBe(1);
- expect(userRows[0]?.id.startsWith("queued-")).toBe(false);
- expect(userRows[0]?.turnId).toBeUndefined();
- const textChunk = userRows[0]?.chunks.find((c) => c.type === "text");
- expect(textChunk && textChunk.type === "text" ? textChunk.text : "").toBe("first\n---\nsecond");
- expect(tab?.queuedMessages.length).toBe(0);
-
- // The next turn-start tags that single collapsed row as its initiator.
- store.handleEvent({ type: "turn-sealed", turnId: "turn-a", tabId: "mc" });
- store.handleEvent({ type: "turn-start", turnId: "turn-b", tabId: "mc" });
- tab = store.tabs.find((t) => t.id === "mc");
- const tagged = tab?.live.filter((m) => m.role === "user" && m.turnId === "turn-b") ?? [];
- expect(tagged.length).toBe(1);
- });
-
- it("preserves a concurrent newer turn when an earlier deferred reconcile flushes", async () => {
- const sealedA = [
- chunkRow("ua", "c", 0, "turn-a", "user", "text", { text: "A?" }),
- chunkRow("aa", "c", 1, "turn-a", "assistant", "text", { text: "A!" }),
- ];
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string) => {
- if (url.split("?")[0]?.endsWith("/tabs/c/chunks"))
- return Promise.resolve(chunksResponse(sealedA, 2));
- return Promise.reject(new Error(`unexpected ${url}`));
- }),
- );
- const store = createTabStore();
- store.handleEvent({
- type: "tab-created",
- id: "c",
- title: "C",
- keyId: null,
- modelId: null,
- parentTabId: null,
- });
- // Turn A streams; user scrolls up; A finishes → reconcile deferred.
- store.handleEvent({ type: "turn-start", turnId: "turn-a", tabId: "c" });
- store.handleEvent({ type: "text-delta", delta: "A!", tabId: "c" });
- store.setScrolledUp("c", true);
- store.handleEvent({ type: "status", status: "idle", tabId: "c" });
- store.handleEvent({ type: "turn-sealed", turnId: "turn-a", tabId: "c" });
- // Turn B (a queued message) starts streaming while still scrolled up.
- store.handleEvent({ type: "turn-start", turnId: "turn-b", tabId: "c" });
- store.handleEvent({ type: "status", status: "running", tabId: "c" });
- store.handleEvent({ type: "text-delta", delta: "B in progress", tabId: "c" });
- let tab = store.tabs.find((t) => t.id === "c");
- expect(tab?.liveTurnId).toBe("turn-b");
- const bId = tab?.currentAssistantId;
- expect(bId).toBeTruthy();
-
- // User scrolls down → the deferred reconcile for turn-a flushes.
- store.setScrolledUp("c", false);
- await tick();
- tab = store.tabs.find((t) => t.id === "c");
- // Turn A folded into the sealed log...
- expect(tab?.chunks.map((c) => c.seq)).toEqual([0, 1]);
- // ...but turn B's in-flight state survived intact (no wipe / no remount).
- expect(tab?.liveTurnId).toBe("turn-b");
- expect(tab?.currentAssistantId).toBe(bId);
- expect(tab?.live.some((m) => m.turnId === "turn-b")).toBe(true);
- expect(tab?.live.some((m) => m.turnId === "turn-a")).toBe(false);
- });
-});
-
-describe("tabStore — tab reorder", () => {
- it("reorders user tabs and persists the new order", async () => {
- const calls: Array<{ url: string; body: string }> = [];
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string, opts?: { body?: string }) => {
- calls.push({ url, body: opts?.body ?? "" });
- return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) });
- }),
- );
- const store = createTabStore();
- const a = await store.createNewTab();
- const b = await store.createNewTab();
- const c = await store.createNewTab();
- expect(store.tabs.map((t) => t.id)).toEqual([a.id, b.id, c.id]);
-
- // Move the last tab to the front.
- store.reorderTabs([c.id, a.id, b.id]);
- expect(store.tabs.map((t) => t.id)).toEqual([c.id, a.id, b.id]);
-
- const reorderCall = calls.find((call) => call.url.endsWith("/tabs/reorder"));
- expect(reorderCall).toBeTruthy();
- expect(JSON.parse(reorderCall?.body ?? "{}")).toEqual({ ids: [c.id, a.id, b.id] });
- });
-
- it("ignores a stale order that doesn't cover all user tabs", async () => {
- vi.stubGlobal(
- "fetch",
- vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })),
- );
- const store = createTabStore();
- const a = await store.createNewTab();
- const b = await store.createNewTab();
- store.reorderTabs([a.id]); // missing b → no-op
- expect(store.tabs.map((t) => t.id)).toEqual([a.id, b.id]);
- });
-
- it("keeps subagent tabs after the user tabs when reordering", async () => {
- vi.stubGlobal(
- "fetch",
- vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })),
- );
- const store = createTabStore();
- const a = await store.createNewTab();
- const b = await store.createNewTab();
- // A subagent tab arrives via WS (parentTabId set).
- store.handleEvent({
- type: "tab-created",
- id: "sub",
- title: "Sub",
- keyId: null,
- modelId: null,
- parentTabId: a.id,
- });
- store.reorderTabs([b.id, a.id]);
- const ids = store.tabs.map((t) => t.id);
- expect(ids).toEqual([b.id, a.id, "sub"]);
- });
-});
-
-describe("tabStore — rename + auto-title guard", () => {
- it("renameTab sets the title and persists it", async () => {
- const calls: Array<{ url: string; method?: string; body: string }> = [];
- vi.stubGlobal(
- "fetch",
- vi.fn((url: string, opts?: { method?: string; body?: string }) => {
- calls.push({ url, method: opts?.method, body: opts?.body ?? "" });
- return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
- }),
- );
- const store = createTabStore();
- const a = await store.createNewTab();
- store.renameTab(a.id, " My Tab ");
- expect(store.tabs[0]?.title).toBe("My Tab");
- expect(store.tabs[0]?.manualTitle).toBe(true);
- const patch = calls.find(
- (call) => call.url.endsWith(`/tabs/${a.id}`) && call.method === "PATCH",
- );
- expect(JSON.parse(patch?.body ?? "{}")).toEqual({ title: "My Tab" });
- });
-
- it("renameTab ignores an empty/whitespace name", async () => {
- vi.stubGlobal(
- "fetch",
- vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })),
- );
- const store = createTabStore();
- const a = await store.createNewTab();
- store.renameTab(a.id, " ");
- expect(store.tabs[0]?.title).toBe("New Tab");
- expect(store.tabs[0]?.manualTitle).toBe(false);
- });
-
- it("sendMessage does NOT auto-title a manually renamed tab", async () => {
- vi.stubGlobal(
- "fetch",
- vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ status: "ok" }) })),
- );
- const store = createTabStore();
- await store.createNewTab();
- store.renameTab(store.tabs[0]?.id ?? "", "Keep Me");
- await store.sendMessage("hello there this is the first message");
- expect(store.tabs[0]?.title).toBe("Keep Me");
- });
-
- it("sendMessage still auto-titles a tab that was never renamed", async () => {
- vi.stubGlobal(
- "fetch",
- vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ status: "ok" }) })),
- );
- const store = createTabStore();
- await store.createNewTab();
- await store.sendMessage("first message becomes the title");
- expect(store.tabs[0]?.title).toBe("first message becomes the title");
- expect(store.tabs[0]?.manualTitle).toBe(false);
- });
-});
-
-describe("tabStore — per-tab chat input draft", () => {
- it("stores drafts per tab and restores them on switch", async () => {
- vi.stubGlobal(
- "fetch",
- vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })),
- );
- const store = createTabStore();
- const a = await store.createNewTab();
- const b = await store.createNewTab();
-
- store.switchTab(a.id);
- store.setDraft(a.id, "draft for A");
- store.switchTab(b.id);
- store.setDraft(b.id, "draft for B");
-
- // Active tab is B → its draft is exposed.
- expect(store.activeTab?.draft).toBe("draft for B");
- // Switching back to A restores A's draft without clobbering B's.
- store.switchTab(a.id);
- expect(store.activeTab?.draft).toBe("draft for A");
- expect(store.tabs.find((t) => t.id === b.id)?.draft).toBe("draft for B");
- });
-
- it("new tabs start with an empty draft and setDraft no-ops for unknown tabs", async () => {
- vi.stubGlobal(
- "fetch",
- vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })),
- );
- const store = createTabStore();
- const a = await store.createNewTab();
- expect(a.draft).toBe("");
- store.setDraft("nope", "ignored"); // unknown tab → no throw, no effect
- expect(store.tabs.every((t) => t.draft === "")).toBe(true);
- });
-});
-
-describe("tabStore — image/pdf attachments", () => {
- function imgAttachment(id: string) {
- return { id, kind: "image" as const, mediaType: "image/png", data: "QQ==" };
- }
-
- it("stages attachments and reconciles them against intact draft tokens", async () => {
- vi.stubGlobal(
- "fetch",
- vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })),
- );
- const store = createTabStore();
- const a = await store.createNewTab();
- store.switchTab(a.id);
-
- store.addAttachment(a.id, imgAttachment("aaaaaa"));
- // Draft carries the token → attachment survives.
- store.setDraft(a.id, "look 【image:aaaaaa】");
- expect(store.activeTab?.attachments.map((x) => x.id)).toEqual(["aaaaaa"]);
-
- // Remove the token from the draft → attachment is detached.
- store.setDraft(a.id, "look ");
- expect(store.activeTab?.attachments).toHaveLength(0);
- });
-
- it("sendMessage posts ordered multimodal content and clears the draft", async () => {
- const fetchMock = vi.fn((url: string) => {
- if (typeof url === "string" && url.endsWith("/chat")) {
- return Promise.resolve({ ok: true, json: () => Promise.resolve({ status: "ok" }) });
- }
- return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
- });
- vi.stubGlobal("fetch", fetchMock);
-
- const store = createTabStore();
- const a = await store.createNewTab();
- store.switchTab(a.id);
-
- await store.sendMessage("here is A: [image]", [
- { type: "text", text: "here is A: " },
- { type: "attachment", mediaType: "image/png", data: "QQ==" },
- ]);
-
- const chatCall = fetchMock.mock.calls.find(
- (c) => typeof c[0] === "string" && (c[0] as string).endsWith("/chat"),
- );
- expect(chatCall).toBeDefined();
- const body = JSON.parse((chatCall?.[1] as { body: string }).body);
- expect(body.message).toBe("here is A: [image]");
- expect(body.content).toEqual([
- { type: "text", text: "here is A: " },
- { type: "attachment", mediaType: "image/png", data: "QQ==" },
- ]);
- });
-
- it("sendMessage omits content for a plain-text message", async () => {
- const fetchMock = vi.fn((url: string) => {
- if (typeof url === "string" && url.endsWith("/chat")) {
- return Promise.resolve({ ok: true, json: () => Promise.resolve({ status: "ok" }) });
- }
- return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
- });
- vi.stubGlobal("fetch", fetchMock);
-
- const store = createTabStore();
- await store.createNewTab();
- await store.sendMessage("just text");
-
- const chatCall = fetchMock.mock.calls.find(
- (c) => typeof c[0] === "string" && (c[0] as string).endsWith("/chat"),
- );
- const body = JSON.parse((chatCall?.[1] as { body: string }).body);
- expect(body.content).toBeUndefined();
- });
-});
diff --git a/packages/frontend/tests/context-window.test.ts b/packages/frontend/tests/context-window.test.ts
deleted file mode 100644
index bb64ed5..0000000
--- a/packages/frontend/tests/context-window.test.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { computeContextUsage } from "../src/lib/context-window.js";
-import type { CacheStats } from "../src/lib/types.js";
-
-function stats(last: CacheStats["last"]): CacheStats {
- return {
- inputTokens: 0,
- outputTokens: 0,
- cacheReadTokens: 0,
- cacheWriteTokens: 0,
- requests: last ? 1 : 0,
- last,
- };
-}
-
-describe("computeContextUsage", () => {
- it("derives current context from the LAST request's input + output", () => {
- const usage = computeContextUsage(
- stats({
- inputTokens: 47000,
- outputTokens: 1200,
- cacheReadTokens: 40000,
- cacheWriteTokens: 0,
- }),
- 200000,
- );
- // 47000 + 1200 — NOT the cumulative totals, and cache tokens are already
- // inside inputTokens (not re-added).
- expect(usage.current).toBe(48200);
- expect(usage.max).toBe(200000);
- expect(usage.percent).toBeCloseTo(24.1, 5); // 48200 / 200000 * 100, unrounded
- });
-
- it("returns max=null and percent=null when the limit is unknown", () => {
- const usage = computeContextUsage(
- stats({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }),
- null,
- );
- expect(usage.current).toBe(100);
- expect(usage.max).toBeNull();
- expect(usage.percent).toBeNull();
- });
-
- it("treats a non-positive limit as unknown", () => {
- const usage = computeContextUsage(
- stats({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }),
- 0,
- );
- expect(usage.max).toBeNull();
- expect(usage.percent).toBeNull();
- });
-
- it("reports zero usage when no request has completed yet", () => {
- expect(computeContextUsage(null, 200000)).toEqual({
- current: 0,
- max: 200000,
- percent: 0,
- });
- expect(computeContextUsage(stats(null), 200000)).toEqual({
- current: 0,
- max: 200000,
- percent: 0,
- });
- });
-
- it("clamps percent to 100 when context overflows the window", () => {
- const usage = computeContextUsage(
- stats({ inputTokens: 250000, outputTokens: 5000, cacheReadTokens: 0, cacheWriteTokens: 0 }),
- 200000,
- );
- expect(usage.current).toBe(255000);
- expect(usage.percent).toBe(100);
- });
-
- it("keeps an unrounded percent so the UI can show 2 decimals", () => {
- const usage = computeContextUsage(
- stats({ inputTokens: 3690, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }),
- 1000000,
- );
- // 3690 / 1,000,000 * 100 = 0.369 → displayed as "0.37%" (toFixed(2)).
- expect(usage.percent).toBeCloseTo(0.369, 6);
- expect((usage.percent as number).toFixed(2)).toBe("0.37");
- });
-});
diff --git a/packages/frontend/tests/sidebar-storage.test.ts b/packages/frontend/tests/sidebar-storage.test.ts
deleted file mode 100644
index 76815b3..0000000
--- a/packages/frontend/tests/sidebar-storage.test.ts
+++ /dev/null
@@ -1,148 +0,0 @@
-/**
- * Tests for `src/lib/sidebar-storage.ts` — the localStorage round-trip
- * for the sidebar panel layout (`panels[].selected`).
- *
- * Bun's `localStorage` shim is partial (`getItem` is missing on a fresh
- * `globalThis`), so we install a clean in-memory polyfill per-test
- * rather than relying on whatever environment-default exists.
- */
-import { beforeEach, describe, expect, it, vi } from "vitest";
-import { loadSidebarPanels, saveSidebarPanels } from "../src/lib/sidebar-storage.js";
-
-const LS_KEY = "dispatch-sidebar-panels";
-
-function makeLocalStorageMock(): Storage {
- const store = new Map<string, string>();
- return {
- getItem: (k: string) => store.get(k) ?? null,
- setItem: (k: string, v: string) => {
- store.set(k, v);
- },
- removeItem: (k: string) => {
- store.delete(k);
- },
- clear: () => {
- store.clear();
- },
- get length() {
- return store.size;
- },
- key: (i: number) => Array.from(store.keys())[i] ?? null,
- };
-}
-
-beforeEach(() => {
- vi.stubGlobal("localStorage", makeLocalStorageMock());
-});
-
-describe("loadSidebarPanels", () => {
- it("returns the default single-panel layout when localStorage is empty", () => {
- expect(loadSidebarPanels()).toEqual(["Chat Settings"]);
- });
-
- it("returns the parsed array when valid JSON is stored", () => {
- localStorage.setItem(LS_KEY, JSON.stringify(["Tasks", "Skills", "Tools"]));
- expect(loadSidebarPanels()).toEqual(["Tasks", "Skills", "Tools"]);
- });
-
- it("preserves order across the round-trip (storage is render-order)", () => {
- const layout = ["Settings", "Chat Settings", "Key Usage", "Config"];
- localStorage.setItem(LS_KEY, JSON.stringify(layout));
- expect(loadSidebarPanels()).toEqual(layout);
- });
-
- it("returns the default when the stored JSON is malformed", () => {
- localStorage.setItem(LS_KEY, "not valid json {[");
- expect(loadSidebarPanels()).toEqual(["Chat Settings"]);
- });
-
- it("returns the default when the stored value is a non-array JSON value", () => {
- localStorage.setItem(LS_KEY, JSON.stringify({ panels: ["Tasks"] }));
- expect(loadSidebarPanels()).toEqual(["Chat Settings"]);
- });
-
- it("returns the default when the stored value is a JSON null", () => {
- localStorage.setItem(LS_KEY, "null");
- expect(loadSidebarPanels()).toEqual(["Chat Settings"]);
- });
-
- it("filters out non-string array entries while keeping the valid ones", () => {
- localStorage.setItem(LS_KEY, JSON.stringify(["Tasks", 42, null, "Skills", true, undefined]));
- expect(loadSidebarPanels()).toEqual(["Tasks", "Skills"]);
- });
-
- it("returns the default when filtering leaves an empty array", () => {
- // Preserves the SidebarPanel "minimum one panel" invariant (the
- // remove-button is hidden on `idx === 0` so the UI can't drop
- // below one panel; load must match).
- localStorage.setItem(LS_KEY, JSON.stringify([1, 2, false, null]));
- expect(loadSidebarPanels()).toEqual(["Chat Settings"]);
- });
-
- it("returns the default when localStorage.getItem throws (SecurityError etc.)", () => {
- vi.stubGlobal("localStorage", {
- getItem: () => {
- throw new Error("SecurityError: storage disabled");
- },
- setItem: () => {},
- removeItem: () => {},
- clear: () => {},
- length: 0,
- key: () => null,
- });
- expect(loadSidebarPanels()).toEqual(["Chat Settings"]);
- });
-
- it("returns a fresh array on each call (callers can mutate the result safely)", () => {
- const a = loadSidebarPanels();
- const b = loadSidebarPanels();
- expect(a).toEqual(b);
- expect(a).not.toBe(b);
- a.push("Tasks");
- expect(b).toEqual(["Chat Settings"]);
- });
-});
-
-describe("saveSidebarPanels", () => {
- it("writes the array as JSON under the canonical key", () => {
- saveSidebarPanels(["Chat Settings", "Tasks"]);
- const raw = localStorage.getItem(LS_KEY);
- expect(raw).toBe(JSON.stringify(["Chat Settings", "Tasks"]));
- });
-
- it("round-trips through load to recover the same value", () => {
- const layout = ["Chat Settings", "Skills", "Tools", "Config"];
- saveSidebarPanels(layout);
- expect(loadSidebarPanels()).toEqual(layout);
- });
-
- it("silently ignores storage errors (quota exceeded, SecurityError, etc.)", () => {
- vi.stubGlobal("localStorage", {
- getItem: () => null,
- setItem: () => {
- throw new Error("QuotaExceededError");
- },
- removeItem: () => {},
- clear: () => {},
- length: 0,
- key: () => null,
- });
- expect(() => saveSidebarPanels(["Tasks"])).not.toThrow();
- });
-
- it("overwrites a prior layout (no append semantics)", () => {
- saveSidebarPanels(["Tasks", "Skills"]);
- saveSidebarPanels(["Config"]);
- expect(loadSidebarPanels()).toEqual(["Config"]);
- });
-
- it("can save an empty array (load will fall back to default on next read)", () => {
- // We don't refuse empty saves at the save site — the layout
- // component enforces the minimum-one-panel invariant by hiding
- // the remove-button on idx 0. If somehow an empty array is
- // passed, we store it; load substitutes the default on read.
- saveSidebarPanels([]);
- expect(localStorage.getItem(LS_KEY)).toBe("[]");
- expect(loadSidebarPanels()).toEqual(["Chat Settings"]);
- });
-});
diff --git a/packages/frontend/tests/snapshot-sequencer.test.ts b/packages/frontend/tests/snapshot-sequencer.test.ts
deleted file mode 100644
index f2c5b8e..0000000
--- a/packages/frontend/tests/snapshot-sequencer.test.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { SnapshotSequencer } from "../src/lib/snapshot-sequencer.js";
-
-describe("SnapshotSequencer", () => {
- it("accepts the first response unconditionally", () => {
- const s = new SnapshotSequencer();
- const seq = s.begin();
- expect(s.accept(seq)).toBe(true);
- });
-
- it("accepts responses in send order", () => {
- const s = new SnapshotSequencer();
- const a = s.begin();
- const b = s.begin();
- const c = s.begin();
- expect(s.accept(a)).toBe(true);
- expect(s.accept(b)).toBe(true);
- expect(s.accept(c)).toBe(true);
- });
-
- it("rejects an older response that arrives AFTER a newer one (the core race)", () => {
- // Sequence: user clicks hour 9 (A), then hour 10 (B). B arrives first.
- const s = new SnapshotSequencer();
- const a = s.begin(); // toggle hour 9
- const b = s.begin(); // toggle hour 10
-
- // B arrives first — applied.
- expect(s.accept(b)).toBe(true);
-
- // A arrives later — must be dropped, else the snapshot from B (which
- // knows about both 9 and 10) gets overwritten by A's stale snapshot
- // (which only knows about 9), and hour 10 vanishes from the UI.
- expect(s.accept(a)).toBe(false);
- });
-
- it("rejects ALL straggler responses once a newer one wins", () => {
- const s = new SnapshotSequencer();
- const a = s.begin();
- const b = s.begin();
- const c = s.begin();
- expect(s.accept(c)).toBe(true);
- expect(s.accept(b)).toBe(false);
- expect(s.accept(a)).toBe(false);
- });
-
- it("handles the initial-load vs first-click race", () => {
- // On mount: $effect fires loadFromServer (seq=1).
- // Before it lands, user clicks a hour (seq=2).
- const s = new SnapshotSequencer();
- const initial = s.begin();
- const click = s.begin();
-
- // Click response arrives first — applied.
- expect(s.accept(click)).toBe(true);
- // Initial load straggles in — must be dropped (it pre-dates the click).
- expect(s.accept(initial)).toBe(false);
- });
-
- it("treats an equal seq as accept (idempotent re-arrival of the winner)", () => {
- const s = new SnapshotSequencer();
- const a = s.begin();
- expect(s.accept(a)).toBe(true);
- // Defensive: same seq accepted again (shouldn't happen in practice
- // but the semantics must be 'no-op accept', not 'reject').
- expect(s.accept(a)).toBe(true);
- });
-
- it("seq numbers are monotonic and unique across begin() calls", () => {
- const s = new SnapshotSequencer();
- const seen = new Set<number>();
- let prev = 0;
- for (let i = 0; i < 100; i++) {
- const seq = s.begin();
- expect(seq).toBeGreaterThan(prev);
- expect(seen.has(seq)).toBe(false);
- seen.add(seq);
- prev = seq;
- }
- });
-
- it("state inspector reflects last-applied watermark", () => {
- const s = new SnapshotSequencer();
- expect(s.state).toEqual({ nextSeq: 0, latestApplied: 0 });
- const a = s.begin();
- const b = s.begin();
- s.accept(b);
- expect(s.state).toEqual({ nextSeq: 2, latestApplied: b });
- // A is too old now — accept() returns false and watermark doesn't move back.
- expect(s.accept(a)).toBe(false);
- expect(s.state.latestApplied).toBe(b);
- });
-});
diff --git a/packages/frontend/tests/theme.test.ts b/packages/frontend/tests/theme.test.ts
deleted file mode 100644
index 63a343d..0000000
--- a/packages/frontend/tests/theme.test.ts
+++ /dev/null
@@ -1,144 +0,0 @@
-/**
- * Tests for `src/lib/theme.ts` — the shared theme picker module.
- *
- * Covers the post-Gemini-review fix where `App.svelte` (boot apply)
- * and `SettingsPanel.svelte` (UI picker) used to hand-roll their own
- * defaults and could disagree. After consolidation, both call into
- * this module and the bug class is gone — these tests pin that
- * invariant.
- */
-import { beforeEach, describe, expect, it, vi } from "vitest";
-import { applyTheme, DEFAULT_THEME, loadStoredTheme, THEMES } from "../src/lib/theme.js";
-
-const LS_KEY = "dispatch-theme";
-
-function makeLocalStorageMock(): Storage {
- const store = new Map<string, string>();
- return {
- getItem: (k: string) => store.get(k) ?? null,
- setItem: (k: string, v: string) => {
- store.set(k, v);
- },
- removeItem: (k: string) => {
- store.delete(k);
- },
- clear: () => {
- store.clear();
- },
- get length() {
- return store.size;
- },
- key: (i: number) => Array.from(store.keys())[i] ?? null,
- };
-}
-
-function makeDocumentMock(): { documentElement: { setAttribute: (k: string, v: string) => void } } {
- const attrs = new Map<string, string>();
- return {
- documentElement: {
- setAttribute: (k: string, v: string) => {
- attrs.set(k, v);
- },
- // expose for assertions
- // @ts-expect-error — test-only escape hatch
- _attrs: attrs,
- },
- };
-}
-
-beforeEach(() => {
- vi.stubGlobal("localStorage", makeLocalStorageMock());
- vi.stubGlobal("document", makeDocumentMock());
-});
-
-describe("DEFAULT_THEME", () => {
- it("is one of the THEMES (sanity check that they can't drift)", () => {
- expect((THEMES as readonly string[]).includes(DEFAULT_THEME)).toBe(true);
- });
-});
-
-describe("loadStoredTheme", () => {
- it("returns DEFAULT_THEME when localStorage is empty (first-ever load)", () => {
- expect(loadStoredTheme()).toBe(DEFAULT_THEME);
- });
-
- it("returns the stored theme when it's a known theme", () => {
- localStorage.setItem(LS_KEY, "dracula");
- expect(loadStoredTheme()).toBe("dracula");
- });
-
- it("returns DEFAULT_THEME when the stored value isn't a known theme", () => {
- // Guards against a stale storage entry from a removed theme,
- // or a hand-edited bad value, falling through to daisyUI's own
- // fallback (which is `light`, not our DEFAULT).
- localStorage.setItem(LS_KEY, "solarized-rainbow");
- expect(loadStoredTheme()).toBe(DEFAULT_THEME);
- });
-
- it("returns DEFAULT_THEME when localStorage.getItem throws", () => {
- vi.stubGlobal("localStorage", {
- getItem: () => {
- throw new Error("SecurityError");
- },
- setItem: () => {},
- removeItem: () => {},
- clear: () => {},
- length: 0,
- key: () => null,
- });
- expect(loadStoredTheme()).toBe(DEFAULT_THEME);
- });
-
- it("returns DEFAULT_THEME when localStorage is undefined (SSR)", () => {
- vi.stubGlobal("localStorage", undefined);
- expect(loadStoredTheme()).toBe(DEFAULT_THEME);
- });
-});
-
-describe("applyTheme", () => {
- it("writes data-theme on the document element", () => {
- applyTheme("nord");
- // @ts-expect-error — test mock exposes `_attrs`
- expect(document.documentElement._attrs.get("data-theme")).toBe("nord");
- });
-
- it("persists the theme to localStorage", () => {
- applyTheme("forest");
- expect(localStorage.getItem(LS_KEY)).toBe("forest");
- });
-
- it("round-trips through loadStoredTheme", () => {
- applyTheme("luxury");
- expect(loadStoredTheme()).toBe("luxury");
- });
-
- it("does not throw when localStorage.setItem throws (quota etc.)", () => {
- vi.stubGlobal("localStorage", {
- getItem: () => null,
- setItem: () => {
- throw new Error("QuotaExceededError");
- },
- removeItem: () => {},
- clear: () => {},
- length: 0,
- key: () => null,
- });
- expect(() => applyTheme("cyberpunk")).not.toThrow();
- });
-
- it("still writes to the DOM even if localStorage write throws", () => {
- vi.stubGlobal("localStorage", {
- getItem: () => null,
- setItem: () => {
- throw new Error("QuotaExceededError");
- },
- removeItem: () => {},
- clear: () => {},
- length: 0,
- key: () => null,
- });
- applyTheme("coffee");
- // @ts-expect-error — test mock exposes `_attrs`
- expect(document.documentElement._attrs.get("data-theme")).toBe("coffee");
- });
-});
diff --git a/packages/frontend/tsconfig.json b/packages/frontend/tsconfig.json
deleted file mode 100644
index 386cd8c..0000000
--- a/packages/frontend/tsconfig.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
- "extends": "../../tsconfig.base.json",
- "compilerOptions": {
- "declaration": false,
- "declarationMap": false,
- "types": ["vite/client"]
- },
- "include": ["src/**/*.ts", "src/**/*.svelte", "src/vite-env.d.ts"],
- "exclude": ["node_modules", "dist"]
-}
diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts
deleted file mode 100644
index 84d1b0c..0000000
--- a/packages/frontend/vite.config.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { svelte } from "@sveltejs/vite-plugin-svelte";
-import tailwindcss from "@tailwindcss/vite";
-// Fallback only. ALWAYS prefer per-icon imports like:
-// import ListIcon from "phosphor-svelte/lib/ListIcon";
-// Named imports from "phosphor-svelte" pull in the full barrel and slow Vite
-// compilation. This plugin rewrites any stray named imports into per-icon
-// default imports at build/dev time, so we don't get crippled if an agent
-// forgets the correct pattern. Treat it as a safety net, not a license to
-// use named imports.
-import { sveltePhosphorOptimize } from "phosphor-svelte/vite";
-import { defineConfig } from "vite";
-
-export default defineConfig({
- base: "./",
- plugins: [tailwindcss(), sveltePhosphorOptimize(), svelte()],
- server: {
- port: 5173,
- allowedHosts: true,
- },
- test: {
- include: ["tests/**/*.test.ts"],
- },
-});