diff options
| author | Adam Malczewski <[email protected]> | 2026-06-04 21:21:20 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-04 21:21:20 +0900 |
| commit | 394f1ed37ce860da6fdc385769bf29f9737105cd (patch) | |
| tree | 4b825dc642cb6eb9a060e54bf8d69288fbee4904 /packages | |
| parent | 81a9cdbadf8c9d940d4fe9a2a0de607dee1f5f1a (diff) | |
| download | dispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.tar.gz dispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.zip | |
chore: genesis — remove all files to rebuild from scratch (arch rewrite)
Diffstat (limited to 'packages')
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 Binary files differdeleted file mode 100644 index 2e2dbb2..0000000 --- a/packages/core/src/agent/agent.ts +++ /dev/null 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">✔</span> - {:else if cwdExists === false} - <span class="text-warning text-lg" title="Directory does not exist (will be created)">✖</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} - > - ▼ - </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 · {entry.data.limits.weekly}/wk · {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, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -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">✔</span> - {:else if cwdExists === false} - <span class="text-warning text-sm" title="Will be created">✖</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" - > - ✕ - </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" - > - ✕ - </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"], - }, -}); |
