From 634da8227e14bb574121b7a000def0b1850a8d4a Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Wed, 22 Jul 2026 12:12:03 -0400 Subject: [PATCH 1/4] =?UTF-8?q?feat(chat):=20subagent=20mode=20(dispatch?= =?UTF-8?q?=20=E2=86=92=20research/write)=20with=20per-thread=20toggle=20+?= =?UTF-8?q?=20live=20trace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional subagent architecture to the in-app AI chat: a thin dispatcher delegates read-only research and write work to focused sub-agents, each with a reduced tool set. On capable models this cut prompt tokens ~43% on research-heavy tasks at equal output quality (benchmarks + LLM-as-judge scorer included). - Extract runToolLoop into shared electron/lib/chat-loop.ts (behaviour unchanged), add optional tool-array override + test-only arg hook. - electron/lib/chat-subagent-loop.ts: runDispatchLoop with research/write sub-agents, forced final-synthesis turn to rescue empty output on weak models, streaming SubagentEvents (start/done/token/thought/tool/usage). - Context accounting: main ring shows the DISPATCHER's context (system + briefs); each sub-agent reports its own usage for a dedicated ring. - Per-thread toggle persisted via schema v29 (chat_threads.use_subagents, chat_messages.subagents) + idempotent ensureColumns guard for version drift. - Renderer: useChatStream accumulates subagents; ChatSubagentBlock renders an expandable live/persisted trace; toolbar toggle (hidden on on-device Llama). - Tests: token/latency benchmark, fault-injection recovery, quality scorer, streaming smoke. --- changelogs/v2.5.4.md | 13 + electron/db/queries.ts | 18 +- electron/db/schema.ts | 39 ++ electron/ipc/chat.ts | 321 ++--------- electron/lib/chat-agent-benchmark.test.ts | 345 ++++++++++++ electron/lib/chat-loop.ts | 305 ++++++++++ electron/lib/chat-quality-scorer.test.ts | 295 ++++++++++ electron/lib/chat-subagent-loop.ts | 530 ++++++++++++++++++ electron/lib/chat-subagent-stream.test.ts | 96 ++++ .../lib/chat-write-recovery-benchmark.test.ts | 240 ++++++++ electron/lib/tools.ts | 2 + electron/preload.ts | 37 ++ electron/shared/db-mappers.ts | 2 + .../chat/chat-panel/ChatMessageBubble.tsx | 8 + .../chat/chat-panel/ChatSubagentBlock.tsx | 103 ++++ src/components/chat/chat-panel/index.tsx | 40 +- src/hooks/useChatStream.ts | 92 ++- src/store/slices/chat.ts | 16 +- src/types/index.ts | 31 + 19 files changed, 2246 insertions(+), 287 deletions(-) create mode 100644 changelogs/v2.5.4.md create mode 100644 electron/lib/chat-agent-benchmark.test.ts create mode 100644 electron/lib/chat-loop.ts create mode 100644 electron/lib/chat-quality-scorer.test.ts create mode 100644 electron/lib/chat-subagent-loop.ts create mode 100644 electron/lib/chat-subagent-stream.test.ts create mode 100644 electron/lib/chat-write-recovery-benchmark.test.ts create mode 100644 src/components/chat/chat-panel/ChatSubagentBlock.tsx diff --git a/changelogs/v2.5.4.md b/changelogs/v2.5.4.md new file mode 100644 index 00000000..24ec12ca --- /dev/null +++ b/changelogs/v2.5.4.md @@ -0,0 +1,13 @@ +# v2.5.4 + +## Added + +- **Subagents in AI chat (per-thread toggle).** A new "Subagents" toggle in the chat toolbar routes a conversation through a dispatch → research/write architecture: a thin dispatcher delegates read-only research and write work to focused sub-agents, each with a smaller tool set. On capable models this cut prompt tokens ~43% on research-heavy tasks at equal output quality. The toggle is per-thread and persisted; hidden on the on-device Llama provider (small models are unreliable with the multi-hop split). +- **Live, expandable subagent trace.** While a subagent-mode turn runs, each sub-agent appears as an inline block you can expand to "step into" it — showing its instruction, streamed findings/confirmation, and its tool calls. Traces persist in thread history. + +## Internal + +- Refactored the in-app chat tool-call loop: extracted `runToolLoop` from `electron/ipc/chat.ts` into a shared `electron/lib/chat-loop.ts` (behaviour unchanged) so it can be reused and benchmarked. Added an optional tool-array override to support restricted tool sets, plus an optional (default no-op) pre-execution arg hook used only by tests for fault injection. +- Added an experimental subagent chat variant (`electron/lib/chat-subagent-loop.ts`) and two benchmarks: a single-agent-vs-subagent token/latency harness (`electron/lib/chat-agent-benchmark.test.ts`) and a write-failure recovery / fault-injection harness (`electron/lib/chat-write-recovery-benchmark.test.ts`). Not wired into production — used to evaluate a research/write/dispatch split for chat. +- Subagent variant robustness: added a forced final-synthesis turn (`tool_choice:"none"` nudge) at the subagent and dispatcher boundaries so weak/local models that stop after their last tool call still return a brief instead of empty output. On a local 4-bit Gemma this eliminated dropped answers and cut subagent prompt tokens ~25% and latency ~35%. +- Added an LLM-as-judge output-quality scorer (`electron/lib/chat-quality-scorer.test.ts`) that grades the actual note artifacts written to the DB against a 5-criterion rubric, with an overridable judge endpoint (`JUDGE_BASE_URL`/`JUDGE_MODEL`/`JUDGE_API_KEY`) so a strong model can fairly grade a weak agent. On a capable model, single-agent and subagent quality were statistically equal (23.7 vs 24.0 / 25). diff --git a/electron/db/queries.ts b/electron/db/queries.ts index 09e1db0d..db01a985 100644 --- a/electron/db/queries.ts +++ b/electron/db/queries.ts @@ -651,14 +651,14 @@ export function getChatThreads(db: Database.Database, workspaceId: string) { } export function upsertChatThread(db: Database.Database, t: { - id: string; scope: string; workspaceId: string; projectId?: string; title?: string; + id: string; scope: string; workspaceId: string; projectId?: string; title?: string; useSubagents?: boolean; }) { const now = ts(); db.prepare(` - INSERT INTO chat_threads (id, scope, workspace_id, project_id, title, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET title = excluded.title, updated_at = excluded.updated_at - `).run(t.id, t.scope, t.workspaceId, t.projectId ?? null, t.title ?? null, now, now); + INSERT INTO chat_threads (id, scope, workspace_id, project_id, title, use_subagents, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET title = excluded.title, use_subagents = excluded.use_subagents, updated_at = excluded.updated_at + `).run(t.id, t.scope, t.workspaceId, t.projectId ?? null, t.title ?? null, t.useSubagents ? 1 : 0, now, now); return toChatThread(db.prepare("SELECT * FROM chat_threads WHERE id = ?").get(t.id)); } @@ -672,13 +672,13 @@ export function getChatMessages(db: Database.Database, threadId: string) { } export function addChatMessage(db: Database.Database, m: { - id: string; threadId: string; role: string; content: string; contextRefs?: unknown; toolCalls?: unknown; reasoning?: string; + id: string; threadId: string; role: string; content: string; contextRefs?: unknown; toolCalls?: unknown; reasoning?: string; subagents?: unknown; }) { const now = ts(); db.prepare(` - INSERT INTO chat_messages (id, thread_id, role, content, context_refs, tool_calls, reasoning, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `).run(m.id, m.threadId, m.role, m.content, m.contextRefs ? JSON.stringify(m.contextRefs) : null, m.toolCalls ? JSON.stringify(m.toolCalls) : null, m.reasoning ?? null, now); + INSERT INTO chat_messages (id, thread_id, role, content, context_refs, tool_calls, reasoning, subagents, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(m.id, m.threadId, m.role, m.content, m.contextRefs ? JSON.stringify(m.contextRefs) : null, m.toolCalls ? JSON.stringify(m.toolCalls) : null, m.reasoning ?? null, m.subagents ? JSON.stringify(m.subagents) : null, now); return toChatMessage(db.prepare("SELECT * FROM chat_messages WHERE id = ?").get(m.id)); } diff --git a/electron/db/schema.ts b/electron/db/schema.ts index 192cae71..af1a1a14 100644 --- a/electron/db/schema.ts +++ b/electron/db/schema.ts @@ -740,6 +740,21 @@ const MIGRATIONS: Migration[] = [ db.prepare("DELETE FROM sync_oplog WHERE entity = ?").run(t); } }, + + // v29: Subagent chat mode. `chat_threads.use_subagents` persists the per-thread + // toggle that routes a conversation through the dispatch → research/write + // subagent loop; `chat_messages.subagents` (JSON) persists the expandable + // subagent traces so they survive restarts (mirrors v14 tool_calls / v20 reasoning). + (db) => { + const threadCols = db.prepare("PRAGMA table_info(chat_threads)").all() as { name: string }[]; + if (!threadCols.some((c) => c.name === "use_subagents")) { + db.exec("ALTER TABLE chat_threads ADD COLUMN use_subagents INTEGER NOT NULL DEFAULT 0"); + } + const msgCols = db.prepare("PRAGMA table_info(chat_messages)").all() as { name: string }[]; + if (!msgCols.some((c) => c.name === "subagents")) { + db.exec("ALTER TABLE chat_messages ADD COLUMN subagents TEXT"); + } + }, ]; export function applySchema(db: Database.Database): void { @@ -747,6 +762,30 @@ export function applySchema(db: Database.Database): void { db.pragma("foreign_keys = ON"); db.exec(SCHEMA_SQL); runMigrations(db); + ensureColumns(db); +} + +/** + * Idempotent, version-independent column guards. Migrations only run for + * `user_version` values below MIGRATIONS.length, so a DB whose user_version was + * already advanced past a migration index (e.g. by an interim/renumbered build) + * can miss a column even though its version looks current. This defensively + * ensures late-added columns exist on every connection, regardless of version. + * Each check is a no-op once the column is present. + */ +function ensureColumns(db: Database.Database): void { + const ensure = (table: string, column: string, ddl: string) => { + try { + const cols = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]; + if (cols.length > 0 && !cols.some((c) => c.name === column)) { + db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`); + } + } catch { + /* table may not exist on some connections — safe to skip */ + } + }; + ensure("chat_threads", "use_subagents", "use_subagents INTEGER NOT NULL DEFAULT 0"); + ensure("chat_messages", "subagents", "subagents TEXT"); } function runMigrations(db: Database.Database): void { diff --git a/electron/ipc/chat.ts b/electron/ipc/chat.ts index c33d87e3..93f2e432 100644 --- a/electron/ipc/chat.ts +++ b/electron/ipc/chat.ts @@ -13,11 +13,9 @@ import { broadcastToChat } from "../chat-popout"; import type Database from "better-sqlite3"; import { isLocalEndpoint, normaliseBaseUrl, type OpenAIMessage, calculatePromptBreakdown, scaleBreakdown, type TokenBreakdown } from "../lib/llm"; import { TOOLS, buildSystemPrompt, type ChatRequest } from "../lib/tools"; -import { executeTool } from "./chat-executor"; -import { getExternalToolDefs, executeExternalTool, isExternalToolName, externalToolLabel } from "../lib/external-tools"; +import { getExternalToolDefs } from "../lib/external-tools"; import { saveCachedConfig, getCachedConfig } from "../lib/config-cache"; -import { iterSseData } from "../lib/sse"; -import { traceTool } from "../lib/tool-trace"; +import { runToolLoop } from "../lib/chat-loop"; // Track one AbortController per renderer webContents ID const abortControllers = new Map(); @@ -69,273 +67,6 @@ function resolveAIConfig(config?: { export type { LLMConfig } from "../lib/llm"; export { callLLM } from "../lib/llm"; -/** - * Run the tool-call loop. Returns when the model produces a response with no - * tool calls (ready to stream) or when the round limit is hit. - */ -async function runToolLoop( - db: Database.Database, - req: ChatRequest, - workspacePath: string, - baseUrl: string, - model: string, - apiKey: string, - messages: OpenAIMessage[], - emitToolCall: (e: { tool: string; label: string; args: Record; callId?: string }) => void, - signal?: AbortSignal, - getWin?: () => BrowserWindow | null, - provider?: string, - onUsage?: (pt: number, ct: number, rt?: number) => void, - emitToolCallDone?: (e: { tool: string; cairnRef?: { type: "note" | "task"; id: string; title: string }; output?: string; callId?: string }) => void, - onToken?: (delta: string) => void, - onThought?: (delta: string) => void, - extraTools: typeof TOOLS = [], -): Promise<{ exhausted: true; content: string; reasoning: string } | { exhausted: false; content: string; reasoning: string }> { - const maxSteps = req.config?.maxSteps ?? 30; - const temperature = req.config?.temperature ?? 0.3; - // Built-in tools + any external tools (MCP servers / custom services) in scope. - const combinedTools = extraTools.length > 0 ? [...TOOLS, ...extraTools] : TOOLS; - let accumulatedContent = ""; - let accumulatedReasoning = ""; - - for (let round = 0; round < maxSteps; round++) { - if (signal?.aborted) return { exhausted: true, content: "", reasoning: accumulatedReasoning }; - - let assistantMsg: OpenAIMessage & { reasoning?: string }; - - if (provider === "localllm") { - try { - const { callLocalLLMChat } = await import("../lib/local-llm"); - const res = await callLocalLLMChat(messages, combinedTools); - const choice = res.choices?.[0]; - if (!choice) return { exhausted: true, content: "No response from local Llama on-device model.", reasoning: "" }; - if (res.usage && onUsage) { - onUsage( - res.usage.prompt_tokens ?? 0, - res.usage.completion_tokens ?? 0, - res.usage.completion_tokens_details?.reasoning_tokens ?? 0, - ); - } - const rawMsg = choice.message as OpenAIMessage & { reasoning?: string }; - if (rawMsg.reasoning) { - accumulatedReasoning += rawMsg.reasoning; - if (onThought) onThought(rawMsg.reasoning); - } - // Strip reasoning before assigning — it must not enter the messages - // array that gets re-sent to the API on subsequent rounds. - const { reasoning: _r, ...msgWithoutReasoning } = rawMsg; - assistantMsg = msgWithoutReasoning; - - // Self-Healing Parser for On-Device XML-style tool calls and tokenizers - if (assistantMsg.content && assistantMsg.content.includes("<|tool_call>call:")) { - const matches = [...assistantMsg.content.matchAll(/<\|tool_call>call:\s*([a-zA-Z0-9_-]+)(.*?)/gs)]; - if (matches.length > 0) { - assistantMsg.tool_calls = assistantMsg.tool_calls || []; - for (const match of matches) { - const fullMatch = match[0]; - const toolName = match[1]; - let argsStr = match[2]; - // Replace tokenized quotes: <|"|> -> " - argsStr = argsStr.replace(/<\|"\|>/g, '"'); - // Wrap unquoted keys in double quotes to ensure strict JSON compliance - argsStr = argsStr.replace(/([{,]\s*)([a-zA-Z0-9_-]+)\s*:/g, '$1"$2":'); - assistantMsg.tool_calls.push({ - id: `call_${Math.random().toString(36).substring(2, 11)}`, - type: "function", - function: { name: toolName, arguments: argsStr } - }); - assistantMsg.content = assistantMsg.content.replace(fullMatch, ""); - } - assistantMsg.content = assistantMsg.content.trim(); - if (!assistantMsg.content) { - assistantMsg.content = null; - } - } - } - if (assistantMsg.content) { - accumulatedContent += assistantMsg.content; - if (onToken) onToken(assistantMsg.content); - } - } catch (err) { - return { exhausted: true, content: `Local LLM Engine error: ${String(err)}`, reasoning: accumulatedReasoning }; - } - } else { - let response: Response; - try { - const headers: Record = { "Content-Type": "application/json" }; - if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; - response = await fetch(`${baseUrl}/v1/chat/completions`, { - method: "POST", - headers, - signal, - body: JSON.stringify({ - model, - messages, - tools: combinedTools, - tool_choice: "auto", - max_tokens: 4096, - temperature, - stream: true, - stream_options: { include_usage: true }, - }), - }); - } catch (_err) { - if (signal?.aborted) return { exhausted: true, content: "", reasoning: accumulatedReasoning }; - return { exhausted: true, content: `Could not reach the AI endpoint at \`${baseUrl}\`. Check your endpoint URL and make sure the server is running.`, reasoning: "" }; - } - - if (!response.ok) { - const errText = await response.text().catch(() => response.statusText); - return { exhausted: true, content: `AI endpoint error (${response.status}): ${errText.slice(0, 300)}`, reasoning: "" }; - } - - const reader = response.body?.getReader(); - if (!reader) return { exhausted: true, content: "No response stream", reasoning: "" }; - - let contentBuffer = ""; - const toolCallBuffers: Map = new Map(); - - for await (const jsonStr of iterSseData(reader, signal ?? undefined)) { - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const chunk = JSON.parse(jsonStr) as any; - if (chunk.usage && onUsage) { - onUsage( - chunk.usage.prompt_tokens ?? 0, - chunk.usage.completion_tokens ?? 0, - chunk.usage.completion_tokens_details?.reasoning_tokens ?? 0, - ); - } - const delta = chunk.choices?.[0]?.delta; - if (!delta) continue; - - if (delta.content) { - contentBuffer += delta.content; - accumulatedContent += delta.content; - if (onToken) onToken(delta.content); - } - - // Reasoning / thinking stream (Claude thinking_delta, OpenAI delta.reasoning). - // Models that don't expose reasoning text simply never emit this field — - // the panel stays hidden. Reasoning is NOT merged into content/tool JSON. - if (delta.reasoning) { - accumulatedReasoning += delta.reasoning; - if (onThought) onThought(delta.reasoning); - } - - if (delta.tool_calls) { - for (const tc of delta.tool_calls) { - const idx: number = tc.index ?? 0; - if (!toolCallBuffers.has(idx)) { - toolCallBuffers.set(idx, { id: tc.id ?? "", name: tc.function?.name ?? "", args: "" }); - } - const buf = toolCallBuffers.get(idx)!; - if (tc.id) buf.id = tc.id; - if (tc.function?.name) buf.name = tc.function.name; - if (tc.function?.arguments) buf.args += tc.function.arguments; - // Gemini 3.x thought signature — opaque blob to round-trip back. - if (tc.thought_signature) buf.thought_signature = tc.thought_signature; - } - } - } catch { /* skip malformed SSE JSON line */ } - } - - // Dev trace: per-tool assembled arguments. - for (const [idx, buf] of toolCallBuffers.entries()) { - traceTool("sse-args", { - toolIndex: idx, - toolName: buf.name, - arguments: buf.args, - }); - } - - if (signal?.aborted) return { exhausted: true, content: "", reasoning: accumulatedReasoning }; - - const toolCalls = toolCallBuffers.size > 0 - ? Array.from(toolCallBuffers.entries()) - .sort(([a], [b]) => a - b) - .map(([, buf]) => ({ - id: buf.id, - type: "function" as const, - function: { name: buf.name, arguments: buf.args }, - ...(buf.thought_signature ? { thought_signature: buf.thought_signature } : {}), - })) - : undefined; - - assistantMsg = { - role: "assistant" as const, - content: contentBuffer || null, - // Note: reasoning is intentionally NOT included here. It is - // accumulated separately in `accumulatedReasoning` and returned - // to the caller for UI/persistence. Sending it back to the API - // would violate both OpenAI and Anthropic message schemas. - tool_calls: toolCalls, - }; - } - - // No tool calls — model is ready to produce its final reply - if (!assistantMsg.tool_calls?.length) { - messages.push(assistantMsg); - return { exhausted: false, content: accumulatedContent, reasoning: accumulatedReasoning }; - } - - messages.push(assistantMsg); - for (const call of assistantMsg.tool_calls) { - let args: Record; - let parseError: string | null = null; - try { - const rawArgs = call.function.arguments?.trim() || "{}"; - args = JSON.parse(rawArgs) as Record; - traceTool("parse", { - toolName: call.function.name, - title: typeof args.title === "string" ? args.title : "", - content: typeof args.content === "string" ? args.content : "", - rawArguments: call.function.arguments || "", - }); - } catch (err) { - parseError = `Malformed tool-call arguments JSON from model: ${(err as Error).message}`; - args = {}; - } - - // Surface the chip so the UI shows the tool happening, then fail - // with a descriptive error so the model can re-issue — never run - // a tool with destructured args. - if (parseError) { - emitToolCall({ tool: call.function.name, label: externalToolLabel(call.function.name, db), args: {}, callId: call.id }); - emitToolCallDone?.({ tool: call.function.name, callId: call.id }); - messages.push({ - role: "tool", - tool_call_id: call.id, - content: JSON.stringify({ error: parseError }), - }); - continue; - } - - let result: unknown; - try { - if (isExternalToolName(call.function.name)) { - // MCP server / custom service tool — route to the external executor. - emitToolCall({ tool: call.function.name, label: externalToolLabel(call.function.name, db), args, callId: call.id }); - const output = await executeExternalTool(db, req.workspaceId ?? "", req.projectId ?? "", call.function.name, args); - emitToolCallDone?.({ tool: call.function.name, output, callId: call.id }); - result = output; - } else { - result = await executeTool(db, req, workspacePath, { baseUrl, model, apiKey, provider: provider as "openai" | "localllm" }, call.function.name, args, emitToolCall, getWin, emitToolCallDone, call.id); - } - } catch (toolErr) { - result = { error: `Tool "${call.function.name}" failed: ${String(toolErr)}` }; - } - messages.push({ role: "tool", tool_call_id: call.id, content: typeof result === "string" ? result : JSON.stringify(result) }); - } - } - - return { - exhausted: true, - content: "I reached the maximum number of steps for this request. Any actions taken so far have been saved — check your board and notes. Try breaking the request into smaller steps.", - reasoning: accumulatedReasoning, - }; -} - export function registerChatHandler(db: Database.Database, workspacePath: string, getWin?: () => BrowserWindow | null): void { registerIpcHandle("chat:compactThread", async (_event, req: { messages: Array<{ role: string; content: string }>; @@ -479,6 +210,54 @@ export function registerChatHandler(db: Database.Database, workspacePath: string send("chat:usage", { promptTokens, completionTokens, reasoningTokens, breakdown: lastBreakdown }); }; + // ── Subagent mode ───────────────────────────────────────────────────────── + // Per-thread toggle routes the turn through the dispatch → research/write + // loop. It streams a live, expandable subagent trace to the renderer via + // chat:subagent* events, then streams the dispatcher's final reply as tokens. + if (req.useSubagents && provider !== "localllm") { + const { runDispatchLoop } = await import("../lib/chat-subagent-loop"); + const dispatchResult = await runDispatchLoop( + db, req, workspacePath, { baseUrl, model, apiKey, provider }, + getWin, + { + events: { + onSubagentStart: (e) => send("chat:subagent", { ...e, status: "start" }), + onSubagentDone: (e) => send("chat:subagent", { ...e, status: "done" }), + onSubagentToken: (e) => send("chat:subagent-token", e), + onSubagentThought: (e) => send("chat:subagent-thought", e), + onSubagentToolCall: (e) => send("chat:subagent-tool-call", e), + onSubagentToolCallDone: (e) => send("chat:subagent-tool-call-done", e), + onSubagentUsage: (e) => send("chat:subagent-usage", e), + }, + }, + ); + + // The main/total ContextRing must reflect the DISPATCHER's context window — + // the system prompt + the subagent briefs fed back — NOT the summed cost of + // every subagent turn. Each subagent reports its own usage via + // chat:subagent-usage for its own ring. (metrics.promptTokens is the total + // cost figure; metrics.dispatcherPromptTokens is the context figure.) + const m = dispatchResult.metrics; + promptTokens = m.dispatcherPromptTokens; + completionTokens = m.dispatcherCompletionTokens; + reasoningTokens = m.dispatcherReasoningTokens; + send("chat:usage", { promptTokens, completionTokens, reasoningTokens, breakdown: lastBreakdown }); + + // Stream the dispatcher's final reply as content tokens so it renders like + // a normal assistant message. + if (dispatchResult.content) send("chat:token", { delta: dispatchResult.content }); + + abortControllers.delete(event.sender.id); + if (!abortCtrl.signal.aborted) broadcastEvent("db:changed", null); + send("chat:done", { + content: dispatchResult.content, + reasoning: dispatchResult.reasoning, + contextRefs: [], + usage: promptTokens > 0 ? { promptTokens, completionTokens, reasoningTokens, breakdown: lastBreakdown } : undefined, + }); + return; + } + const loopResult = await runToolLoop( db, req, workspacePath, baseUrl, model, apiKey, messages, emitToolCall, abortCtrl.signal, getWin, provider, addUsage, diff --git a/electron/lib/chat-agent-benchmark.test.ts b/electron/lib/chat-agent-benchmark.test.ts new file mode 100644 index 00000000..0d91b856 --- /dev/null +++ b/electron/lib/chat-agent-benchmark.test.ts @@ -0,0 +1,345 @@ +/** + * Cairn — Single-agent vs. Subagent chat benchmark + * + * Compares two chat architectures on research-heavy "read a lot, then write a + * synthesis note" tasks: + * + * 1. SINGLE — the real runToolLoop (electron/lib/chat-loop.ts), all ~45 tools, + * one context. This is production behaviour. + * 2. SUBAGENT — runDispatchLoop (electron/lib/chat-subagent-loop.ts): a thin + * dispatcher delegating to a read-only research subagent and a + * write-only writing subagent, each with a reduced tool array. + * + * Two suites: + * • "tool-array context cost" — offline, always runs. Measures the per-role + * first-message tool-schema token cost (the context reduction the split buys). + * • "live end-to-end" — gated on a reachable OpenAI-compatible endpoint. Runs + * the task set through both architectures against a real model and logs + * tokens / latency / tool-calls / errors to CSV. + * + * Endpoint config (reuses the repo's existing live-test convention): + * TEST_LLM_BASE_URL e.g. https://api.openai.com or http://localhost:1234/v1 + * TEST_LLM_MODEL e.g. gpt-4o-mini + * TEST_LLM_API_KEY (omit for local no-key endpoints) + * CAIRN_SKIP_LIVE_TESTS=1 to force-skip the live suite + * + * Run: + * TEST_LLM_BASE_URL=... TEST_LLM_MODEL=... TEST_LLM_API_KEY=... \ + * npx vitest run electron/lib/chat-agent-benchmark.test.ts + */ + +import { describe, it, beforeEach, afterEach, expect } from "vitest"; +import type Database from "better-sqlite3"; +import BetterSqlite3 from "better-sqlite3"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { encode } from "gpt-tokenizer"; +import { applySchema } from "../db/schema"; +import { + createWorkspace, createProject, createNote, createColumn, createCard, + updateNote, updateCard, createTag, +} from "../db/queries"; +import { TOOLS, type ChatRequest } from "./tools"; +import { normaliseBaseUrl } from "./llm"; +import { runToolLoop } from "./chat-loop"; +import { + runDispatchLoop, RESEARCH_TOOL_NAMES, WRITE_TOOL_NAMES, type SubagentMetrics, +} from "./chat-subagent-loop"; + +// ── Endpoint config ───────────────────────────────────────────────────────── + +const BASE_URL = normaliseBaseUrl( + process.env.TEST_LLM_BASE_URL?.trim() || "http://localhost:1234/v1", +); +const MODEL = process.env.TEST_LLM_MODEL?.trim() || "gpt-4o-mini"; +const API_KEY = process.env.TEST_LLM_API_KEY?.trim() || ""; + +const tok = (s: string) => encode(s).length; + +/** Serialise a tool the way the API sees it, for token accounting. */ +function toolStr(t: { function: { name: string; description: string; parameters: unknown } }): string { + return JSON.stringify({ + type: "function", + function: { name: t.function.name, description: t.function.description, parameters: t.function.parameters }, + }); +} + +function toolArrayTokens(names?: ReadonlySet): number { + const list = names ? (TOOLS as ReadonlyArray).filter((t) => names.has(t.function.name)) : TOOLS; + return list.reduce((sum, t) => sum + tok(toolStr(t)), 0); +} + +async function endpointUp(): Promise { + try { + const res = await fetch(`${BASE_URL}/v1/models`, { + headers: API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}, + signal: AbortSignal.timeout(2500), + }); + return res.ok || res.status === 401 || res.status === 404; + } catch { + return false; + } +} + +// ── Seed a representative workspace ─────────────────────────────────────────── + +function makeDb() { + const db = new BetterSqlite3(":memory:"); + applySchema(db); + return db; +} + +const WS = "ws-bench"; +const PROJ = "proj-bench"; +const COL_BACKLOG = "col-backlog"; +const COL_TODO = "col-todo"; +const COL_DONE = "col-done"; + +function seed(db: Database.Database) { + createWorkspace(db, { id: WS, name: "Bench Workspace" }); + createProject(db, { id: PROJ, workspaceId: WS, name: "Cairn", description: "Desktop app", priority: "high", icon: "🪨" }); + createColumn(db, { id: COL_BACKLOG, projectId: PROJ, workspaceId: WS, name: "Backlog", type: "backlog", order: 0 }); + createColumn(db, { id: COL_TODO, projectId: PROJ, workspaceId: WS, name: "Todo", type: "todo", order: 1 }); + createColumn(db, { id: COL_DONE, projectId: PROJ, workspaceId: WS, name: "Done", type: "done", order: 2 }); + + createTag(db, { id: "tg-mobile", workspaceId: WS, name: "mobile", color: "#22c55e" }); + createTag(db, { id: "tg-ai", workspaceId: WS, name: "ai", color: "#6366f1" }); + createTag(db, { id: "tg-perf", workspaceId: WS, name: "performance", color: "#ef4444" }); + + // A spread of realistic notes so research has something to synthesise. + const notes: Array<[string, string, string, string[]]> = [ + ["n-sem-search", "Mobile Semantic Search — on-device findings", + "Explored NLContextualEmbedding on iOS for on-device semantic search over notes. Works on iOS 17+; macOS 14+ shares the API. Latency ~40ms/query on A17. Next: unify with desktop embeddings.", ["tg-mobile", "tg-ai"]], + ["n-sem-search-2", "Semantic search score floor", + "Semantic results need a similarity floor to avoid noisy low-score matches. Plumbing (topK) exists; needs real-data tuning, not mechanical. Deferred past v2.5.0.", ["tg-ai"]], + ["n-release", "v2.5.0 Release Plan", + "Shipped: tag-assignment tools, semantic task search (review), binary verification. Deferred to v2.6: score floor, chunked code indexing, mobile theme toggle.", ["tg-perf"]], + ["n-dogfood", "Dogfooding Log v2.5.0", + "Personas used Cairn to plan the release. Found: list_ready_tasks NOT a bug; indexer missed const/type/interface (FIXED). Net: 1 real bug, 1 false alarm.", []], + ["n-code-idx", "Code Semantic Search — jina-code PoC", + "Chunked semantic code indexing with jina-code 768-d embeddings. Query 'how does auth work' returned the right files. Verified. Task to productionise is medium priority.", ["tg-ai", "tg-perf"]], + ["n-mobile-matrix", "Mobile Feature Matrix", + "Desktop vs companion parity: chat, board, notes, sync done. Visualization out of companion scope. Semantic search is a mobile strength via on-device embeddings.", ["tg-mobile"]], + ["n-perf", "Perf notes — payload optimization", + "Trimmed MCP tool payloads ~10% by stripping JSON-schema noise (pattern/min/max/default). ~500 tok/turn saved. Validated by tool-schema-optimization test.", ["tg-perf"]], + ["n-misc", "Random idea — voice capture", + "Idea: voice capture for quick notes on mobile. Low priority, deferred.", []], + ]; + for (const [id, title, content, tagIds] of notes) { + createNote(db, { id, projectId: PROJ, workspaceId: WS, title, content, tagIds }); + } + updateNote(db, "n-sem-search", { linkedNoteIds: ["n-sem-search-2", "n-mobile-matrix"] }); + + const cards: Array<[string, string, string, string, string, string[]]> = [ + ["c-unify-sem", COL_BACKLOG, "Unify semantic search on Mac via NLContextualEmbedding", "Follow-up from mobile on-device work. macOS 14+ shares the API.", "low", ["tg-mobile", "tg-ai"]], + ["c-score-floor", COL_BACKLOG, "Semantic search score floor", "Deferred past v2.5.0 — needs real-data tuning.", "low", ["tg-ai"]], + ["c-code-idx", COL_TODO, "Chunked semantic code indexing (jina-code, 768-d)", "PoC verified. Productionise.", "medium", ["tg-ai", "tg-perf"]], + ["c-reasoning", COL_TODO, "Handle reasoning models in local-llm", "Content fallback + higher max_tokens from benchmark.", "medium", ["tg-ai"]], + ["c-shipped", COL_DONE, "Semantic task search (desktop + mobile)", "Shipped in v2.5.0 review.", "medium", ["tg-ai"]], + ]; + for (const [id, columnId, title, description, priority, tagIds] of cards) { + createCard(db, { id, columnId, projectId: PROJ, workspaceId: WS, title, description, priority: priority as "low" | "medium" | "high" | "urgent", tagIds }); + } + updateCard(db, "c-unify-sem", { linkedNoteIds: ["n-sem-search"] }); +} + +// ── Benchmark task set ──────────────────────────────────────────────────────── + +interface BenchTask { + id: string; + prompt: string; + /** Category, for reading the crossover in results. */ + kind: "research+write" | "research" | "write" | "trivial"; +} + +const TASKS: BenchTask[] = [ + { + id: "T1", kind: "research+write", + prompt: "Read the notes and open tasks in this project, identify the main themes, and write a new note titled 'Themes Synthesis' capturing them.", + }, + { + id: "T2", kind: "research", + prompt: "Find everything related to mobile semantic search across notes and tasks, and summarise the current status in your reply (do not write a note).", + }, + { + id: "T3", kind: "research+write", + prompt: "Compare what shipped vs what was deferred for v2.5.0 based on the notes, and write a note 'v2.5.0 Shipped vs Deferred'.", + }, + { + id: "T5", kind: "trivial", + prompt: "Create a task titled 'Write release notes for v2.6' in the Backlog.", + }, +]; + +// ── Metric accumulation for the single-agent path ───────────────────────────── + +interface RunRecord { + arch: "single" | "subagent"; + taskId: string; + kind: string; + promptTokens: number; + completionTokens: number; + reasoningTokens: number; + latencyMs: number; + toolCalls: number; + toolErrors: number; + subagentRuns: number; + finalChars: number; +} + +function baseReq(prompt: string): ChatRequest { + return { + message: prompt, + threadId: "bench-thread", + workspaceId: WS, + projectId: PROJ, + config: { maxSteps: 20, temperature: 0.2 }, + }; +} + +async function runSingle(db: Database.Database, wp: string, task: BenchTask): Promise { + const req = baseReq(task.prompt); + const messages = [ + { role: "system" as const, content: "" }, // filled below via buildSystemPrompt-equivalent + { role: "user" as const, content: task.prompt }, + ]; + // Use the real system prompt. + const { buildSystemPrompt } = await import("./tools"); + messages[0].content = buildSystemPrompt(req); + + let promptTokens = 0, completionTokens = 0, reasoningTokens = 0, toolCalls = 0, toolErrors = 0; + const t0 = Date.now(); + const result = await runToolLoop( + db, req, wp, BASE_URL, MODEL, API_KEY, messages, + () => { toolCalls += 1; }, + undefined, undefined, "openai", + (pt, ct, rt) => { promptTokens += pt; completionTokens += ct; if (rt) reasoningTokens += rt; }, + (e) => { if (e.output) { try { if (JSON.parse(e.output)?.error) toolErrors += 1; } catch { /* ignore */ } } }, + ); + const latencyMs = Date.now() - t0; + + return { + arch: "single", taskId: task.id, kind: task.kind, + promptTokens, completionTokens, reasoningTokens, latencyMs, + toolCalls, toolErrors, subagentRuns: 0, + finalChars: result.content.length, + }; +} + +async function runSub(db: Database.Database, wp: string, task: BenchTask): Promise { + const req = baseReq(task.prompt); + const t0 = Date.now(); + const { content, metrics }: { content: string; metrics: SubagentMetrics } = + await runDispatchLoop(db, req, wp, { baseUrl: BASE_URL, model: MODEL, apiKey: API_KEY, provider: "openai" }); + const latencyMs = Date.now() - t0; + return { + arch: "subagent", taskId: task.id, kind: task.kind, + promptTokens: metrics.promptTokens, + completionTokens: metrics.completionTokens, + reasoningTokens: metrics.reasoningTokens, + latencyMs, + toolCalls: metrics.toolCalls, + toolErrors: metrics.toolErrors, + subagentRuns: metrics.subagentRuns, + finalChars: content.length, + }; +} + +function toCsv(rows: RunRecord[]): string { + const header = "arch,task,kind,promptTok,completionTok,reasoningTok,latencyMs,toolCalls,toolErrors,subagentRuns,finalChars"; + const lines = rows.map((r) => + [r.arch, r.taskId, r.kind, r.promptTokens, r.completionTokens, r.reasoningTokens, r.latencyMs, r.toolCalls, r.toolErrors, r.subagentRuns, r.finalChars].join(",")); + return [header, ...lines].join("\n"); +} + +// ── Suite 1: offline tool-array context cost ───────────────────────────────── + +describe("chat agent benchmark — tool-array context cost (offline)", () => { + it("measures the per-role first-message tool-schema token cost", () => { + const single = toolArrayTokens(); // all built-in tools + const research = toolArrayTokens(RESEARCH_TOOL_NAMES); + const write = toolArrayTokens(WRITE_TOOL_NAMES); + // Dispatcher advertises only 2 synthetic tools (~120 tok); approximate here. + const dispatch = 130; + + const rows = [ + "role,tools,firstMsgToolTokens", + `single-agent,${TOOLS.length},${single}`, + `research-subagent,${RESEARCH_TOOL_NAMES.size},${research}`, + `write-subagent,${WRITE_TOOL_NAMES.size},${write}`, + `dispatcher,2,${dispatch}`, + ]; + // eslint-disable-next-line no-console + console.log("\n=== Tool-array context cost (tokens sent EVERY turn) ===\n" + rows.join("\n")); + + const worstSubagentTurn = Math.max(research, write, dispatch); + // eslint-disable-next-line no-console + console.log( + `\nSingle-agent pays ${single} tok/turn for tools.` + + `\nWorst subagent turn pays ${worstSubagentTurn} tok/turn (${Math.round((1 - worstSubagentTurn / single) * 100)}% less).`, + ); + + // Sanity: each restricted set must be strictly smaller than the full array. + expect(research).toBeLessThan(single); + expect(write).toBeLessThan(single); + expect(worstSubagentTurn).toBeLessThan(single); + }); +}); + +// ── Suite 2: live end-to-end benchmark ─────────────────────────────────────── + +describe.skipIf(!!process.env.CAIRN_SKIP_LIVE_TESTS)("chat agent benchmark — live end-to-end", () => { + let up = false; + beforeEach(async () => { up = await endpointUp(); }); + + it("runs the task set through both architectures and logs CSV", async () => { + if (!up) { + // eslint-disable-next-line no-console + console.log(`[skip] No LLM endpoint reachable at ${BASE_URL}. Set TEST_LLM_BASE_URL/MODEL/API_KEY to run the live benchmark.`); + return; + } + + const rows: RunRecord[] = []; + const wp = fs.mkdtempSync(path.join(os.tmpdir(), "cairn-bench-")); + + for (const task of TASKS) { + // Fresh DB per architecture per task so writes from one don't bias the other. + for (const arch of ["single", "subagent"] as const) { + const db = makeDb(); + seed(db); + try { + const rec = arch === "single" ? await runSingle(db, wp, task) : await runSub(db, wp, task); + rows.push(rec); + // eslint-disable-next-line no-console + console.log( + `${arch.padEnd(9)} ${task.id} ptok=${String(rec.promptTokens).padStart(6)} ` + + `ctok=${String(rec.completionTokens).padStart(5)} ${String(rec.latencyMs).padStart(6)}ms ` + + `calls=${rec.toolCalls} err=${rec.toolErrors} subs=${rec.subagentRuns} out=${rec.finalChars}c`, + ); + } finally { + db.close(); + } + } + } + + const csv = toCsv(rows); + const outPath = path.join(wp, "chat-agent-benchmark.csv"); + fs.writeFileSync(outPath, csv); + // eslint-disable-next-line no-console + console.log("\n=== CSV ===\n" + csv + "\n\nWritten to: " + outPath); + + // Aggregate: per-arch totals. + for (const arch of ["single", "subagent"] as const) { + const a = rows.filter((r) => r.arch === arch); + const sum = (f: (r: RunRecord) => number) => a.reduce((s, r) => s + f(r), 0); + // eslint-disable-next-line no-console + console.log( + `TOTAL ${arch.padEnd(9)} ptok=${sum((r) => r.promptTokens)} ctok=${sum((r) => r.completionTokens)} ` + + `latency=${sum((r) => r.latencyMs)}ms calls=${sum((r) => r.toolCalls)} errors=${sum((r) => r.toolErrors)}`, + ); + } + + expect(rows.length).toBe(TASKS.length * 2); + }, 600_000); +}); diff --git a/electron/lib/chat-loop.ts b/electron/lib/chat-loop.ts new file mode 100644 index 00000000..cde98408 --- /dev/null +++ b/electron/lib/chat-loop.ts @@ -0,0 +1,305 @@ +/** + * Cairn — Chat tool-call loop (extracted from ipc/chat.ts) + * + * `runToolLoop` drives the OpenAI-compatible completions loop for the in-app + * chat assistant: it sends messages + tools with tool_choice "auto", parses the + * SSE stream, executes any tool calls, appends their results, and repeats until + * the model returns a response with no tool calls (ready to stream) or the + * step limit is reached. + * + * This was lifted out of `electron/ipc/chat.ts` verbatim (behaviour unchanged) + * so it can be reused by (a) the IPC handler and (b) the subagent variant / + * benchmark harness without duplicating the loop. `chat.ts` imports it. + */ + +import type { BrowserWindow } from "electron"; +import type Database from "better-sqlite3"; +import type { OpenAIMessage } from "./llm"; +import { TOOLS, type ChatRequest } from "./tools"; +import { executeTool } from "../ipc/chat-executor"; +import { executeExternalTool, isExternalToolName, externalToolLabel } from "./external-tools"; +import { iterSseData } from "./sse"; +import { traceTool } from "./tool-trace"; + +export type RunToolLoopResult = + | { exhausted: true; content: string; reasoning: string } + | { exhausted: false; content: string; reasoning: string }; + +/** + * Run the tool-call loop. Returns when the model produces a response with no + * tool calls (ready to stream) or when the round limit is hit. + * + * `toolsOverride` lets callers (e.g. the research/write subagents) restrict the + * advertised tool set. When omitted, the full built-in TOOLS array is used. + */ +export async function runToolLoop( + db: Database.Database, + req: ChatRequest, + workspacePath: string, + baseUrl: string, + model: string, + apiKey: string, + messages: OpenAIMessage[], + emitToolCall: (e: { tool: string; label: string; args: Record; callId?: string }) => void, + signal?: AbortSignal, + getWin?: () => BrowserWindow | null, + provider?: string, + onUsage?: (pt: number, ct: number, rt?: number) => void, + emitToolCallDone?: (e: { tool: string; cairnRef?: { type: "note" | "task"; id: string; title: string }; output?: string; callId?: string }) => void, + onToken?: (delta: string) => void, + onThought?: (delta: string) => void, + extraTools: typeof TOOLS = [], + toolsOverride?: typeof TOOLS, + /** + * Optional pre-execution hook to transform tool-call args (test/benchmark + * seam — e.g. fault injection). Production never passes this; default is the + * identity transform. Runs after JSON parse, before executeTool. + */ + argMutator?: (name: string, args: Record) => Record, +): Promise { + const maxSteps = req.config?.maxSteps ?? 30; + const temperature = req.config?.temperature ?? 0.3; + // Built-in tools (or a caller-supplied override) + any external tools in scope. + const baseTools = toolsOverride ?? TOOLS; + const combinedTools = extraTools.length > 0 ? [...baseTools, ...extraTools] : baseTools; + let accumulatedContent = ""; + let accumulatedReasoning = ""; + + for (let round = 0; round < maxSteps; round++) { + if (signal?.aborted) return { exhausted: true, content: "", reasoning: accumulatedReasoning }; + + let assistantMsg: OpenAIMessage & { reasoning?: string }; + + if (provider === "localllm") { + try { + const { callLocalLLMChat } = await import("./local-llm"); + const res = await callLocalLLMChat(messages, combinedTools); + const choice = res.choices?.[0]; + if (!choice) return { exhausted: true, content: "No response from local Llama on-device model.", reasoning: "" }; + if (res.usage && onUsage) { + onUsage( + res.usage.prompt_tokens ?? 0, + res.usage.completion_tokens ?? 0, + res.usage.completion_tokens_details?.reasoning_tokens ?? 0, + ); + } + const rawMsg = choice.message as OpenAIMessage & { reasoning?: string }; + if (rawMsg.reasoning) { + accumulatedReasoning += rawMsg.reasoning; + if (onThought) onThought(rawMsg.reasoning); + } + // Strip reasoning before assigning — it must not enter the messages + // array that gets re-sent to the API on subsequent rounds. + const { reasoning: _r, ...msgWithoutReasoning } = rawMsg; + assistantMsg = msgWithoutReasoning; + + // Self-Healing Parser for On-Device XML-style tool calls and tokenizers + if (assistantMsg.content && assistantMsg.content.includes("<|tool_call>call:")) { + const matches = [...assistantMsg.content.matchAll(/<\|tool_call>call:\s*([a-zA-Z0-9_-]+)(.*?)/gs)]; + if (matches.length > 0) { + assistantMsg.tool_calls = assistantMsg.tool_calls || []; + for (const match of matches) { + const fullMatch = match[0]; + const toolName = match[1]; + let argsStr = match[2]; + // Replace tokenized quotes: <|"|> -> " + argsStr = argsStr.replace(/<\|"\|>/g, '"'); + // Wrap unquoted keys in double quotes to ensure strict JSON compliance + argsStr = argsStr.replace(/([{,]\s*)([a-zA-Z0-9_-]+)\s*:/g, '$1"$2":'); + assistantMsg.tool_calls.push({ + id: `call_${Math.random().toString(36).substring(2, 11)}`, + type: "function", + function: { name: toolName, arguments: argsStr } + }); + assistantMsg.content = assistantMsg.content.replace(fullMatch, ""); + } + assistantMsg.content = assistantMsg.content.trim(); + if (!assistantMsg.content) { + assistantMsg.content = null; + } + } + } + if (assistantMsg.content) { + accumulatedContent += assistantMsg.content; + if (onToken) onToken(assistantMsg.content); + } + } catch (err) { + return { exhausted: true, content: `Local LLM Engine error: ${String(err)}`, reasoning: accumulatedReasoning }; + } + } else { + let response: Response; + try { + const headers: Record = { "Content-Type": "application/json" }; + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + response = await fetch(`${baseUrl}/v1/chat/completions`, { + method: "POST", + headers, + signal, + body: JSON.stringify({ + model, + messages, + tools: combinedTools, + tool_choice: "auto", + max_tokens: 4096, + temperature, + stream: true, + stream_options: { include_usage: true }, + }), + }); + } catch (_err) { + if (signal?.aborted) return { exhausted: true, content: "", reasoning: accumulatedReasoning }; + return { exhausted: true, content: `Could not reach the AI endpoint at \`${baseUrl}\`. Check your endpoint URL and make sure the server is running.`, reasoning: "" }; + } + + if (!response.ok) { + const errText = await response.text().catch(() => response.statusText); + return { exhausted: true, content: `AI endpoint error (${response.status}): ${errText.slice(0, 300)}`, reasoning: "" }; + } + + const reader = response.body?.getReader(); + if (!reader) return { exhausted: true, content: "No response stream", reasoning: "" }; + + let contentBuffer = ""; + const toolCallBuffers: Map = new Map(); + + for await (const jsonStr of iterSseData(reader, signal ?? undefined)) { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const chunk = JSON.parse(jsonStr) as any; + if (chunk.usage && onUsage) { + onUsage( + chunk.usage.prompt_tokens ?? 0, + chunk.usage.completion_tokens ?? 0, + chunk.usage.completion_tokens_details?.reasoning_tokens ?? 0, + ); + } + const delta = chunk.choices?.[0]?.delta; + if (!delta) continue; + + if (delta.content) { + contentBuffer += delta.content; + accumulatedContent += delta.content; + if (onToken) onToken(delta.content); + } + + // Reasoning / thinking stream (Claude thinking_delta, OpenAI delta.reasoning). + // Models that don't expose reasoning text simply never emit this field — + // the panel stays hidden. Reasoning is NOT merged into content/tool JSON. + if (delta.reasoning) { + accumulatedReasoning += delta.reasoning; + if (onThought) onThought(delta.reasoning); + } + + if (delta.tool_calls) { + for (const tc of delta.tool_calls) { + const idx: number = tc.index ?? 0; + if (!toolCallBuffers.has(idx)) { + toolCallBuffers.set(idx, { id: tc.id ?? "", name: tc.function?.name ?? "", args: "" }); + } + const buf = toolCallBuffers.get(idx)!; + if (tc.id) buf.id = tc.id; + if (tc.function?.name) buf.name = tc.function.name; + if (tc.function?.arguments) buf.args += tc.function.arguments; + // Gemini 3.x thought signature — opaque blob to round-trip back. + if (tc.thought_signature) buf.thought_signature = tc.thought_signature; + } + } + } catch { /* skip malformed SSE JSON line */ } + } + + // Dev trace: per-tool assembled arguments. + for (const [idx, buf] of toolCallBuffers.entries()) { + traceTool("sse-args", { + toolIndex: idx, + toolName: buf.name, + arguments: buf.args, + }); + } + + if (signal?.aborted) return { exhausted: true, content: "", reasoning: accumulatedReasoning }; + + const toolCalls = toolCallBuffers.size > 0 + ? Array.from(toolCallBuffers.entries()) + .sort(([a], [b]) => a - b) + .map(([, buf]) => ({ + id: buf.id, + type: "function" as const, + function: { name: buf.name, arguments: buf.args }, + ...(buf.thought_signature ? { thought_signature: buf.thought_signature } : {}), + })) + : undefined; + + assistantMsg = { + role: "assistant" as const, + content: contentBuffer || null, + // Note: reasoning is intentionally NOT included here. It is + // accumulated separately in `accumulatedReasoning` and returned + // to the caller for UI/persistence. Sending it back to the API + // would violate both OpenAI and Anthropic message schemas. + tool_calls: toolCalls, + }; + } + + // No tool calls — model is ready to produce its final reply + if (!assistantMsg.tool_calls?.length) { + messages.push(assistantMsg); + return { exhausted: false, content: accumulatedContent, reasoning: accumulatedReasoning }; + } + + messages.push(assistantMsg); + for (const call of assistantMsg.tool_calls) { + let args: Record; + let parseError: string | null = null; + try { + const rawArgs = call.function.arguments?.trim() || "{}"; + args = JSON.parse(rawArgs) as Record; + traceTool("parse", { + toolName: call.function.name, + title: typeof args.title === "string" ? args.title : "", + content: typeof args.content === "string" ? args.content : "", + rawArguments: call.function.arguments || "", + }); + } catch (err) { + parseError = `Malformed tool-call arguments JSON from model: ${(err as Error).message}`; + args = {}; + } + + // Surface the chip so the UI shows the tool happening, then fail + // with a descriptive error so the model can re-issue — never run + // a tool with destructured args. + if (parseError) { + emitToolCall({ tool: call.function.name, label: externalToolLabel(call.function.name, db), args: {}, callId: call.id }); + emitToolCallDone?.({ tool: call.function.name, callId: call.id }); + messages.push({ + role: "tool", + tool_call_id: call.id, + content: JSON.stringify({ error: parseError }), + }); + continue; + } + + let result: unknown; + try { + if (argMutator) args = argMutator(call.function.name, args); + if (isExternalToolName(call.function.name)) { + // MCP server / custom service tool — route to the external executor. + emitToolCall({ tool: call.function.name, label: externalToolLabel(call.function.name, db), args, callId: call.id }); + const output = await executeExternalTool(db, req.workspaceId ?? "", req.projectId ?? "", call.function.name, args); + emitToolCallDone?.({ tool: call.function.name, output, callId: call.id }); + result = output; + } else { + result = await executeTool(db, req, workspacePath, { baseUrl, model, apiKey, provider: provider as "openai" | "localllm" }, call.function.name, args, emitToolCall, getWin, emitToolCallDone, call.id); + } + } catch (toolErr) { + result = { error: `Tool "${call.function.name}" failed: ${String(toolErr)}` }; + } + messages.push({ role: "tool", tool_call_id: call.id, content: typeof result === "string" ? result : JSON.stringify(result) }); + } + } + + return { + exhausted: true, + content: "I reached the maximum number of steps for this request. Any actions taken so far have been saved — check your board and notes. Try breaking the request into smaller steps.", + reasoning: accumulatedReasoning, + }; +} diff --git a/electron/lib/chat-quality-scorer.test.ts b/electron/lib/chat-quality-scorer.test.ts new file mode 100644 index 00000000..fd2ac67b --- /dev/null +++ b/electron/lib/chat-quality-scorer.test.ts @@ -0,0 +1,295 @@ +/** + * Cairn — Output-quality scorer (LLM-as-judge) for single vs. subagent chat + * + * The token/latency harness (chat-agent-benchmark.test.ts) measures cost. This + * one measures QUALITY of the actual deliverable — the note/task artifacts + * written to the DB plus the final chat reply — scored against the 5-point + * rubric from the experiment note by an LLM judge. + * + * Rubric (each 1–5): + * R1 task_completion — did it produce the asked-for artifact (note/task)? + * R2 theme_coverage — captures the actual themes present in the source corpus? + * R3 faithfulness — no hallucinated notes/tasks/ids/claims? + * R4 structure — concise, well-structured markdown? + * R5 actionability — links/tags/next-steps where appropriate? + * + * The judge SHOULD be a strong model even if the agent runs on a weak one, so + * the score reflects quality, not the judge's own limits. Override with + * JUDGE_BASE_URL / JUDGE_MODEL / JUDGE_API_KEY; otherwise falls back to the + * TEST_LLM_* endpoint used to run the agents. + * + * Run: + * TEST_LLM_BASE_URL=... TEST_LLM_MODEL=... TEST_LLM_API_KEY=... \ + * [JUDGE_MODEL=... JUDGE_BASE_URL=... JUDGE_API_KEY=...] \ + * npx vitest run --project node electron/lib/chat-quality-scorer.test.ts + */ + +import { describe, it, beforeEach, expect } from "vitest"; +import type Database from "better-sqlite3"; +import BetterSqlite3 from "better-sqlite3"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { applySchema } from "../db/schema"; +import { + createWorkspace, createProject, createColumn, createNote, createCard, + updateNote, updateCard, createTag, getFullSnapshot, +} from "../db/queries"; +import { buildSystemPrompt, type ChatRequest } from "./tools"; +import { normaliseBaseUrl } from "./llm"; +import { runToolLoop } from "./chat-loop"; +import { runDispatchLoop } from "./chat-subagent-loop"; + +const BASE_URL = normaliseBaseUrl(process.env.TEST_LLM_BASE_URL?.trim() || "http://localhost:1234/v1"); +const MODEL = process.env.TEST_LLM_MODEL?.trim() || "gpt-4o-mini"; +const API_KEY = process.env.TEST_LLM_API_KEY?.trim() || ""; + +// Judge endpoint — defaults to the agent endpoint if not overridden. +const JUDGE_BASE_URL = normaliseBaseUrl(process.env.JUDGE_BASE_URL?.trim() || BASE_URL); +const JUDGE_MODEL = process.env.JUDGE_MODEL?.trim() || MODEL; +const JUDGE_API_KEY = process.env.JUDGE_API_KEY?.trim() || API_KEY; + +// ── Seed (same corpus as chat-agent-benchmark) ──────────────────────────────── + +const WS = "ws-q"; +const PROJ = "proj-q"; +const COL_BACKLOG = "col-b"; +const COL_TODO = "col-t"; +const COL_DONE = "col-d"; + +function makeDb() { + const db = new BetterSqlite3(":memory:"); + applySchema(db); + return db; +} + +/** The ground-truth source corpus, also handed to the judge for theme/faithfulness checks. */ +const SOURCE_NOTES: Array<{ id: string; title: string; content: string; tagIds: string[] }> = [ + { id: "n-sem-search", title: "Mobile Semantic Search — on-device findings", tagIds: ["tg-mobile", "tg-ai"], + content: "Explored NLContextualEmbedding on iOS for on-device semantic search over notes. Works on iOS 17+; macOS 14+ shares the API. Latency ~40ms/query on A17. Next: unify with desktop embeddings." }, + { id: "n-sem-search-2", title: "Semantic search score floor", tagIds: ["tg-ai"], + content: "Semantic results need a similarity floor to avoid noisy low-score matches. Plumbing (topK) exists; needs real-data tuning, not mechanical. Deferred past v2.5.0." }, + { id: "n-release", title: "v2.5.0 Release Plan", tagIds: ["tg-perf"], + content: "Shipped: tag-assignment tools, semantic task search (review), binary verification. Deferred to v2.6: score floor, chunked code indexing, mobile theme toggle." }, + { id: "n-dogfood", title: "Dogfooding Log v2.5.0", tagIds: [], + content: "Personas used Cairn to plan the release. Found: list_ready_tasks NOT a bug; indexer missed const/type/interface (FIXED). Net: 1 real bug, 1 false alarm." }, + { id: "n-code-idx", title: "Code Semantic Search — jina-code PoC", tagIds: ["tg-ai", "tg-perf"], + content: "Chunked semantic code indexing with jina-code 768-d embeddings. Query 'how does auth work' returned the right files. Verified. Task to productionise is medium priority." }, + { id: "n-mobile-matrix", title: "Mobile Feature Matrix", tagIds: ["tg-mobile"], + content: "Desktop vs companion parity: chat, board, notes, sync done. Visualization out of companion scope. Semantic search is a mobile strength via on-device embeddings." }, + { id: "n-perf", title: "Perf notes — payload optimization", tagIds: ["tg-perf"], + content: "Trimmed MCP tool payloads ~10% by stripping JSON-schema noise (pattern/min/max/default). ~500 tok/turn saved. Validated by tool-schema-optimization test." }, + { id: "n-misc", title: "Random idea — voice capture", tagIds: [], + content: "Idea: voice capture for quick notes on mobile. Low priority, deferred." }, +]; + +const SOURCE_CARDS: Array<{ id: string; col: string; title: string; description: string; priority: string; tagIds: string[] }> = [ + { id: "c-unify-sem", col: COL_BACKLOG, title: "Unify semantic search on Mac via NLContextualEmbedding", description: "Follow-up from mobile on-device work. macOS 14+ shares the API.", priority: "low", tagIds: ["tg-mobile", "tg-ai"] }, + { id: "c-score-floor", col: COL_BACKLOG, title: "Semantic search score floor", description: "Deferred past v2.5.0 — needs real-data tuning.", priority: "low", tagIds: ["tg-ai"] }, + { id: "c-code-idx", col: COL_TODO, title: "Chunked semantic code indexing (jina-code, 768-d)", description: "PoC verified. Productionise.", priority: "medium", tagIds: ["tg-ai", "tg-perf"] }, + { id: "c-reasoning", col: COL_TODO, title: "Handle reasoning models in local-llm", description: "Content fallback + higher max_tokens from benchmark.", priority: "medium", tagIds: ["tg-ai"] }, + { id: "c-shipped", col: COL_DONE, title: "Semantic task search (desktop + mobile)", description: "Shipped in v2.5.0 review.", priority: "medium", tagIds: ["tg-ai"] }, +]; + +function seed(db: Database.Database) { + createWorkspace(db, { id: WS, name: "Quality WS" }); + createProject(db, { id: PROJ, workspaceId: WS, name: "Cairn", description: "Desktop app", priority: "high", icon: "🪨" }); + createColumn(db, { id: COL_BACKLOG, projectId: PROJ, workspaceId: WS, name: "Backlog", type: "backlog", order: 0 }); + createColumn(db, { id: COL_TODO, projectId: PROJ, workspaceId: WS, name: "Todo", type: "todo", order: 1 }); + createColumn(db, { id: COL_DONE, projectId: PROJ, workspaceId: WS, name: "Done", type: "done", order: 2 }); + createTag(db, { id: "tg-mobile", workspaceId: WS, name: "mobile", color: "#22c55e" }); + createTag(db, { id: "tg-ai", workspaceId: WS, name: "ai", color: "#6366f1" }); + createTag(db, { id: "tg-perf", workspaceId: WS, name: "performance", color: "#ef4444" }); + for (const n of SOURCE_NOTES) createNote(db, { id: n.id, projectId: PROJ, workspaceId: WS, title: n.title, content: n.content, tagIds: n.tagIds }); + updateNote(db, "n-sem-search", { linkedNoteIds: ["n-sem-search-2", "n-mobile-matrix"] }); + for (const c of SOURCE_CARDS) createCard(db, { id: c.id, columnId: c.col, projectId: PROJ, workspaceId: WS, title: c.title, description: c.description, priority: c.priority as "low" | "medium" | "high" | "urgent", tagIds: c.tagIds }); + updateCard(db, "c-unify-sem", { linkedNoteIds: ["n-sem-search"] }); +} + +// ── Tasks (subset of the benchmark that produces a note deliverable) ────────── + +interface QTask { id: string; prompt: string; } +const TASKS: QTask[] = [ + { id: "T1", prompt: "Read the notes and open tasks in this project, identify the main themes, and write a new note titled 'Themes Synthesis' capturing them." }, + { id: "T2", prompt: "Find everything related to mobile semantic search across notes and tasks, and write a note 'Mobile Semantic Search — Status' summarising the current state, what's shipped, and what's deferred." }, + { id: "T3", prompt: "Compare what shipped vs what was deferred for v2.5.0 based on the notes, and write a note 'v2.5.0 Shipped vs Deferred'." }, +]; + +// ── Artifact capture: diff snapshot before/after a run ──────────────────────── + +interface Artifact { notes: Array<{ id: string; title: string; content: string }>; cards: Array<{ id: string; title: string }>; } + +function captureNew(db: Database.Database, beforeNoteIds: Set, beforeCardIds: Set): Artifact { + const snap = getFullSnapshot(db); + const notes = snap.notes + .filter((n) => !beforeNoteIds.has(n.id as string)) + .map((n) => ({ id: n.id as string, title: n.title as string, content: (n.content as string) ?? "" })); + const cards = snap.cards + .filter((c) => !beforeCardIds.has(c.id as string)) + .map((c) => ({ id: c.id as string, title: c.title as string })); + return { notes, cards }; +} + +async function endpointUp(url: string, key: string): Promise { + try { + const res = await fetch(`${url}/v1/models`, { headers: key ? { Authorization: `Bearer ${key}` } : {}, signal: AbortSignal.timeout(2500) }); + return res.ok || res.status === 401 || res.status === 404; + } catch { return false; } +} + +// ── The judge ───────────────────────────────────────────────────────────────── + +interface RubricScore { task_completion: number; theme_coverage: number; faithfulness: number; structure: number; actionability: number; comment: string; } + +const CRITERIA: (keyof Omit)[] = ["task_completion", "theme_coverage", "faithfulness", "structure", "actionability"]; + +function sourceCorpusText(): string { + const notes = SOURCE_NOTES.map((n) => `- [${n.id}] "${n.title}": ${n.content}`).join("\n"); + const cards = SOURCE_CARDS.map((c) => `- [${c.id}] "${c.title}" (${c.priority}, ${c.col}): ${c.description}`).join("\n"); + return `NOTES:\n${notes}\n\nTASKS:\n${cards}`; +} + +async function judge(task: QTask, finalReply: string, artifact: Artifact): Promise { + const deliverable = artifact.notes.length + ? artifact.notes.map((n) => `### Note "${n.title}"\n${n.content}`).join("\n\n") + : "(no note was created)"; + const createdCards = artifact.cards.length ? artifact.cards.map((c) => `- ${c.title}`).join("\n") : "(none)"; + + const system = + "You are a strict, fair evaluator of an AI assistant's output inside a note-taking app. " + + "Score ONLY against the rubric and the provided source corpus. Penalise hallucinated facts/ids not grounded in the corpus. " + + "Return ONLY a JSON object, no prose."; + + const user = +`SOURCE CORPUS (ground truth the assistant had access to): +${sourceCorpusText()} + +USER REQUEST: +${task.prompt} + +ASSISTANT FINAL REPLY: +${finalReply || "(empty)"} + +DELIVERABLE ARTIFACT (note written to the workspace): +${deliverable} + +CARDS CREATED: ${createdCards} + +Score each criterion 1–5 (5 best): +- task_completion: produced the asked-for artifact (a note) with a sensible title/content. +- theme_coverage: captures the actual themes present in the source corpus (mobile semantic search, code indexing, perf/payload, release shipped/deferred, dogfooding). +- faithfulness: no hallucinated notes/tasks/ids/claims absent from the corpus. +- structure: concise, well-structured markdown (headings/lists as appropriate). +- actionability: surfaces next steps / shipped-vs-deferred / links where the request implies it. + +Return JSON exactly like: +{"task_completion":N,"theme_coverage":N,"faithfulness":N,"structure":N,"actionability":N,"comment":"one sentence"}`; + + const headers: Record = { "Content-Type": "application/json" }; + if (JUDGE_API_KEY) headers["Authorization"] = `Bearer ${JUDGE_API_KEY}`; + + const res = await fetch(`${JUDGE_BASE_URL}/v1/chat/completions`, { + method: "POST", headers, + body: JSON.stringify({ + model: JUDGE_MODEL, + messages: [{ role: "system", content: system }, { role: "user", content: user }], + max_tokens: 700, temperature: 0, stream: false, + }), + }); + if (!res.ok) throw new Error(`judge error ${res.status}: ${await res.text().catch(() => "")}`); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = await res.json() as any; + let text: string = data.choices?.[0]?.message?.content ?? ""; + // Strip code fences / extract the JSON object. + text = text.replace(/^```(?:json)?/i, "").replace(/```$/i, "").trim(); + const match = text.match(/\{[\s\S]*\}/); + const parsed = JSON.parse(match ? match[0] : text) as RubricScore; + // Clamp to 1–5. + for (const c of CRITERIA) parsed[c] = Math.max(1, Math.min(5, Math.round(Number(parsed[c]) || 1))); + return parsed; +} + +// ── Run one task under one architecture, capturing the artifact + reply ─────── + +function baseReq(prompt: string): ChatRequest { + return { message: prompt, threadId: "q-thread", workspaceId: WS, projectId: PROJ, config: { maxSteps: 20, temperature: 0.2 } }; +} + +async function runSingle(db: Database.Database, wp: string, task: QTask): Promise<{ reply: string; artifact: Artifact }> { + const before = getFullSnapshot(db); + const beforeNotes = new Set(before.notes.map((n) => n.id as string)); + const beforeCards = new Set(before.cards.map((c) => c.id as string)); + const req = baseReq(task.prompt); + const messages = [{ role: "system" as const, content: buildSystemPrompt(req) }, { role: "user" as const, content: task.prompt }]; + const r = await runToolLoop(db, req, wp, BASE_URL, MODEL, API_KEY, messages, () => {}, undefined, undefined, "openai"); + return { reply: r.content, artifact: captureNew(db, beforeNotes, beforeCards) }; +} + +async function runSub(db: Database.Database, wp: string, task: QTask): Promise<{ reply: string; artifact: Artifact }> { + const before = getFullSnapshot(db); + const beforeNotes = new Set(before.notes.map((n) => n.id as string)); + const beforeCards = new Set(before.cards.map((c) => c.id as string)); + const req = baseReq(task.prompt); + const r = await runDispatchLoop(db, req, wp, { baseUrl: BASE_URL, model: MODEL, apiKey: API_KEY, provider: "openai" }); + return { reply: r.content, artifact: captureNew(db, beforeNotes, beforeCards) }; +} + +interface ScoreRow { arch: string; task: string; s: RubricScore; total: number; noteChars: number; } + +describe.skipIf(!!process.env.CAIRN_SKIP_LIVE_TESTS)("chat output-quality scorer (LLM-as-judge, live)", () => { + let up = false; + beforeEach(async () => { up = (await endpointUp(BASE_URL, API_KEY)) && (await endpointUp(JUDGE_BASE_URL, JUDGE_API_KEY)); }); + + it("scores single vs subagent deliverables against the rubric", async () => { + if (!up) { + // eslint-disable-next-line no-console + console.log(`[skip] agent endpoint ${BASE_URL} or judge ${JUDGE_BASE_URL} unreachable.`); + return; + } + // eslint-disable-next-line no-console + console.log(`Agent: ${MODEL} @ ${BASE_URL} Judge: ${JUDGE_MODEL} @ ${JUDGE_BASE_URL}`); + + const wp = fs.mkdtempSync(path.join(os.tmpdir(), "cairn-quality-")); + const rows: ScoreRow[] = []; + + for (const task of TASKS) { + for (const arch of ["single", "subagent"] as const) { + const db = makeDb(); seed(db); + try { + const { reply, artifact } = arch === "single" ? await runSingle(db, wp, task) : await runSub(db, wp, task); + const s = await judge(task, reply, artifact); + const total = CRITERIA.reduce((sum, c) => sum + s[c], 0); + const noteChars = artifact.notes.reduce((sum, n) => sum + n.content.length, 0); + rows.push({ arch, task: task.id, s, total, noteChars }); + // eslint-disable-next-line no-console + console.log( + `${arch.padEnd(9)} ${task.id} total=${total}/25 ` + + `[tc=${s.task_completion} th=${s.theme_coverage} f=${s.faithfulness} st=${s.structure} act=${s.actionability}] ` + + `noteChars=${noteChars} — ${s.comment}`, + ); + } finally { db.close(); } + } + } + + const header = "arch,task,task_completion,theme_coverage,faithfulness,structure,actionability,total,noteChars"; + const csv = [header, ...rows.map((r) => + [r.arch, r.task, r.s.task_completion, r.s.theme_coverage, r.s.faithfulness, r.s.structure, r.s.actionability, r.total, r.noteChars].join(","))].join("\n"); + fs.writeFileSync(path.join(wp, "quality-scores.csv"), csv); + // eslint-disable-next-line no-console + console.log("\n=== CSV ===\n" + csv); + + for (const arch of ["single", "subagent"] as const) { + const a = rows.filter((r) => r.arch === arch); + const avg = (f: (r: ScoreRow) => number) => (a.reduce((s, r) => s + f(r), 0) / a.length).toFixed(2); + // eslint-disable-next-line no-console + console.log( + `AVG ${arch.padEnd(9)} total=${avg((r) => r.total)}/25 ` + + `tc=${avg((r) => r.s.task_completion)} th=${avg((r) => r.s.theme_coverage)} ` + + `f=${avg((r) => r.s.faithfulness)} st=${avg((r) => r.s.structure)} act=${avg((r) => r.s.actionability)} ` + + `noteChars=${avg((r) => r.noteChars)}`, + ); + } + + expect(rows.length).toBe(TASKS.length * 2); + }, 900_000); +}); diff --git a/electron/lib/chat-subagent-loop.ts b/electron/lib/chat-subagent-loop.ts new file mode 100644 index 00000000..cdcec214 --- /dev/null +++ b/electron/lib/chat-subagent-loop.ts @@ -0,0 +1,530 @@ +/** + * Cairn — Subagent chat variant (experimental) + * + * A dispatch → research + write architecture for the in-app chat, built to be + * benchmarked against the single-agent `runToolLoop` (electron/lib/chat-loop.ts). + * + * Dispatch agent — thin orchestrator. Tools: research, write. Delegates. + * Research agent — READ-ONLY tool subset. Returns a compact findings brief. + * Writing agent — WRITE tool subset. Receives the brief, performs mutations. + * + * Each subagent runs the shared `runToolLoop` with a `toolsOverride` restricting + * the advertised tool array (fewer prompt tokens/turn) and a fresh message + * history (context isolation). Only the subagent's final message returns to the + * dispatcher, keeping the parent context lean — the same principle the Pi coding + * agent's spawn_subagent uses. + * + * This module is NOT wired into production IPC. It exists so the benchmark + * harness can drive a faithful subagent variant using the real tool executor. + */ + +import type { BrowserWindow } from "electron"; +import type Database from "better-sqlite3"; +import type { OpenAIMessage } from "./llm"; +import { TOOLS, type ChatRequest } from "./tools"; +import { runToolLoop, type RunToolLoopResult } from "./chat-loop"; + +/** Loosely-typed OpenAI function tool — the synthetic dispatch tools ("research", + * "write") aren't in the schema-derived `typeof TOOLS` union, so we widen. */ +type ChatTool = { + type: "function"; + function: { name: string; description: string; parameters: unknown }; +}; + +// ── Tool partitioning ───────────────────────────────────────────────────────── + +/** Read-only tools the research subagent is allowed to advertise. */ +export const RESEARCH_TOOL_NAMES: ReadonlySet = new Set([ + "get_active_context", + "get_project_context_pack", + "get_note", + "get_task", + "search_notes", + "search_notes_semantic", + "search_tasks", + "search_tasks_semantic", + "list_ready_tasks", + "list_overdue_tasks", + "list_tasks_due", + "list_folders", + "list_templates", + "get_knowledge_graph", + "get_neighbors", + "get_semantic_neighbors", + "get_idea_flow", +]); + +/** Write tools the writing subagent is allowed to advertise. */ +export const WRITE_TOOL_NAMES: ReadonlySet = new Set([ + "get_active_context", + "get_note", + "ensure_note", + "append_to_note", + "patch_note", + "rename_note", + "bulk_move_notes", + "create_task", + "update_task", + "bulk_update_task_status", + "create_tag", + "tag_note", + "tag_task", + "link_note_to_task", + "unlink_note_from_task", + "instantiate_template", + "suggest_connections", + "create_idea_flow_node", + "create_idea_flow_edge", +]); + +/** + * STRICT write set — mutation-only, NO read tools. Used to test the "pure write + * access" failure mode: on a failed patch the writer cannot get_note/search to + * self-correct, so the failure must escalate to the dispatcher (ping-pong). + */ +export const WRITE_TOOL_NAMES_STRICT: ReadonlySet = new Set([ + "get_active_context", + "ensure_note", + "append_to_note", + "patch_note", + "rename_note", + "bulk_move_notes", + "create_task", + "update_task", + "bulk_update_task_status", + "create_tag", + "tag_note", + "tag_task", + "link_note_to_task", + "unlink_note_from_task", + "instantiate_template", + "suggest_connections", + "create_idea_flow_node", + "create_idea_flow_edge", +]); + +function filterTools(allowed: ReadonlySet): ChatTool[] { + return (TOOLS as unknown as ChatTool[]).filter((t) => allowed.has(t.function.name)); +} + +// ── Synthetic dispatch tools ──────────────────────────────────────────────── + +const DISPATCH_TOOLS: ChatTool[] = [ + { + type: "function" as const, + function: { + name: "research", + description: + "Delegate a read-only research task to a focused sub-agent. The sub-agent can search and read notes, tasks, the knowledge graph and idea flow, then returns a concise findings brief. Use this to gather everything you need BEFORE writing. Provide a specific, self-contained instruction.", + parameters: { + type: "object", + properties: { + instruction: { + type: "string", + description: "What to research. Be specific about what to find and what to include in the brief.", + }, + }, + required: ["instruction"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "write", + description: + "Delegate a write task to a focused sub-agent that can create/modify notes and tasks, apply tags, and link items. Pass along any findings the writer needs (IDs, titles, themes) in the instruction — the writer does NOT see the research brief automatically. Provide a specific, self-contained instruction.", + parameters: { + type: "object", + properties: { + instruction: { + type: "string", + description: "What to write/modify, including the concrete content and any IDs/titles the writer needs.", + }, + }, + required: ["instruction"], + }, + }, + }, +]; + +// ── Metrics ─────────────────────────────────────────────────────────────────── + +export interface SubagentMetrics { + /** SUM of prompt tokens across the dispatcher + every subagent turn (total cost). */ + promptTokens: number; + completionTokens: number; + reasoningTokens: number; + /** + * The DISPATCHER's own context usage — the prompt tokens on its last turn. + * This is what the main/total ContextRing should show: the dispatcher only + * ever holds the system prompt + the subagent briefs fed back, NOT the raw + * tool outputs the subagents processed. Much smaller than `promptTokens`. + */ + dispatcherPromptTokens: number; + dispatcherCompletionTokens: number; + dispatcherReasoningTokens: number; + toolCalls: number; + toolErrors: number; + subagentRuns: number; + /** How many times the dispatcher invoked the WRITE subagent. >1 = ping-pong. */ + writeInvocations: number; + /** How many times the dispatcher invoked the RESEARCH subagent. */ + researchInvocations: number; +} + +export interface SubagentRunResult { + content: string; + reasoning: string; + metrics: SubagentMetrics; +} + +interface DispatchConfig { + baseUrl: string; + model: string; + apiKey: string; + provider?: string; +} + +/** + * Streaming callbacks so the renderer can show a live, expandable trace of each + * subagent (mirrors the Pi coding agent's pi-agent:subagent pattern). All events + * carry a `childId` so the UI can group them under the right subagent block. + */ +export interface SubagentEvents { + /** A subagent started. role is "research" | "write". */ + onSubagentStart?: (e: { childId: string; role: string; instruction: string }) => void; + /** A subagent finished; `result` is the brief returned to the dispatcher. */ + onSubagentDone?: (e: { childId: string; role: string; result: string }) => void; + /** A content token streamed by a subagent. */ + onSubagentToken?: (e: { childId: string; delta: string }) => void; + /** A reasoning/thinking token streamed by a subagent. */ + onSubagentThought?: (e: { childId: string; delta: string }) => void; + /** A tool call started inside a subagent. */ + onSubagentToolCall?: (e: { childId: string; tool: string; label: string; args: Record; callId?: string }) => void; + /** A tool call finished inside a subagent. */ + onSubagentToolCallDone?: (e: { childId: string; tool: string; cairnRef?: { type: "note" | "task"; id: string; title: string }; output?: string; callId?: string }) => void; + /** Latest token usage for a subagent (its OWN context window) — drives its ring. */ + onSubagentUsage?: (e: { childId: string; promptTokens: number; completionTokens: number; reasoningTokens?: number }) => void; +} + +const DISPATCH_SYSTEM_PROMPT = (date: string) => + `You are the Cairn AI dispatcher — an orchestrator embedded in a note-taking and project-management app. Today is ${date}. + +You do NOT read or write the workspace directly. You accomplish tasks by delegating to two sub-agents: +- **research(instruction)** — a read-only agent that searches and reads notes/tasks/graph and returns a findings brief. +- **write(instruction)** — an agent that creates/modifies notes and tasks. It does NOT see research output unless you include it in the instruction. + +Workflow: +1. For anything requiring knowledge of the workspace, call \`research\` first with a specific instruction. +2. When you need to create/modify anything, call \`write\` and PASS the concrete content + any note/task IDs and titles the writer needs (copy them from the research brief). +3. For trivial writes that need no lookup, you may call \`write\` directly. +4. When done, give the user a brief, concrete confirmation. Keep replies concise markdown.`; + +const RESEARCH_SYSTEM_PROMPT = (date: string) => + `You are a focused research sub-agent inside Cairn. Today is ${date}. +Use the read-only tools to gather what the instruction asks for. Call get_active_context first to obtain workspaceId/projectId/columnId; never invent IDs. +Return a CONCISE findings brief: the relevant note/task titles WITH their IDs, the key themes/facts, and anything the caller needs to act. Do not write or modify anything.`; + +const WRITE_SYSTEM_PROMPT = (date: string) => + `You are a focused writing sub-agent inside Cairn. Today is ${date}. +Use the write tools to carry out the instruction exactly. Call get_active_context first if you need IDs; never invent IDs. Prefer the IDs/titles supplied in the instruction. After acting, briefly confirm what you did.`; + +/** + * Run one subagent turn via the shared runToolLoop with a restricted tool set + * and a fresh history. Accumulates usage/tool metrics into `metrics`. + */ +async function runSubagent( + db: Database.Database, + req: ChatRequest, + workspacePath: string, + cfg: DispatchConfig, + systemPrompt: string, + instruction: string, + allowedTools: ReadonlySet, + metrics: SubagentMetrics, + getWin?: () => BrowserWindow | null, + argMutator?: (name: string, args: Record) => Record, + childId?: string, + events?: SubagentEvents, +): Promise { + metrics.subagentRuns += 1; + const messages: OpenAIMessage[] = [ + { role: "system", content: systemPrompt }, + { role: "user", content: instruction }, + ]; + + const result = await runToolLoop( + db, + req, + workspacePath, + cfg.baseUrl, + cfg.model, + cfg.apiKey, + messages, + (e) => { + metrics.toolCalls += 1; + if (childId) events?.onSubagentToolCall?.({ childId, tool: e.tool, label: e.label, args: e.args, callId: e.callId }); + }, + undefined, + getWin, + cfg.provider, + (pt, ct, rt) => { + // Accumulate into the total-cost figures… + metrics.promptTokens += pt; metrics.completionTokens += ct; if (rt) metrics.reasoningTokens += rt; + // …and report THIS subagent's own context window (its latest prompt size) + // so the renderer can give it a dedicated ring. prompt_tokens is the size + // of the context this turn, not a running sum — take the latest value. + if (childId) events?.onSubagentUsage?.({ childId, promptTokens: pt, completionTokens: ct, reasoningTokens: rt }); + }, + (e) => { + // Count tool errors by sniffing the JSON output for an error field. + if (e.output) { + try { if (JSON.parse(e.output)?.error) metrics.toolErrors += 1; } catch { /* non-JSON output */ } + } + if (childId) events?.onSubagentToolCallDone?.({ childId, tool: e.tool, cairnRef: e.cairnRef, output: e.output, callId: e.callId }); + }, + childId ? (delta) => events?.onSubagentToken?.({ childId, delta }) : undefined, + childId ? (delta) => events?.onSubagentThought?.({ childId, delta }) : undefined, + [], + filterTools(allowedTools) as unknown as typeof TOOLS, + argMutator, + ); + + const content = result.content.trim(); + if (content) return content; + + // Weak models frequently stop after their last tool call and emit an empty + // final turn — the runToolLoop history has all the gathered tool outputs but + // no synthesised brief. Force ONE more turn with tool_choice:"none" and an + // explicit nudge to write the findings. This is the standard "force final + // answer" pattern and rescues most empty-output cases on small models. + if (metrics.toolCalls > 0 || messages.some((m) => m.role === "tool")) { + const forced = await forceFinalAnswer(cfg, messages, metrics); + if (forced.trim()) { + if (childId) events?.onSubagentToken?.({ childId, delta: forced.trim() }); + return forced.trim(); + } + } + + return "(sub-agent produced no output)"; +} + +/** + * Do one non-streaming completion with tools DISABLED, nudging the model to + * produce its final text answer from the conversation gathered so far. Used to + * recover when a subagent stopped without emitting its brief/confirmation. + */ +async function forceFinalAnswer( + cfg: DispatchConfig, + messages: OpenAIMessage[], + metrics: SubagentMetrics, +): Promise { + const nudged: OpenAIMessage[] = [ + ...messages, + { + role: "user", + content: + "Based on everything you have gathered above, write your final answer now as plain text. " + + "Do NOT call any tools. If you were researching, produce the concise findings brief " + + "(relevant titles WITH ids, key themes/facts). If you were writing, confirm what you did.", + }, + ]; + + const headers: Record = { "Content-Type": "application/json" }; + if (cfg.apiKey) headers["Authorization"] = `Bearer ${cfg.apiKey}`; + + try { + const response = await fetch(`${cfg.baseUrl}/v1/chat/completions`, { + method: "POST", + headers, + body: JSON.stringify({ + model: cfg.model, + messages: nudged, + tool_choice: "none", + max_tokens: 4096, + temperature: 0.2, + stream: false, + }), + }); + if (!response.ok) return ""; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = await response.json() as any; + if (data.usage) { + metrics.promptTokens += data.usage.prompt_tokens ?? 0; + metrics.completionTokens += data.usage.completion_tokens ?? 0; + metrics.reasoningTokens += data.usage.completion_tokens_details?.reasoning_tokens ?? 0; + } + return (data.choices?.[0]?.message?.content as string) ?? ""; + } catch { + return ""; + } +} + +/** + * Dispatch loop: advertises only `research` and `write`. When the model calls + * one, we spawn the matching subagent and feed its final answer back as the + * tool result. Mirrors runToolLoop's streaming/tool-call parsing but with a + * 2-tool array and synthetic tool handling. + */ +export interface DispatchOptions { + /** Tool set for the writing subagent. Defaults to WRITE_TOOL_NAMES (includes get_note). */ + writeTools?: ReadonlySet; + /** Tool set for the research subagent. Defaults to RESEARCH_TOOL_NAMES. */ + researchTools?: ReadonlySet; + /** Test/benchmark seam: transform tool args before execution (e.g. fault injection). */ + argMutator?: (name: string, args: Record) => Record; + /** Streaming callbacks for a live, expandable subagent trace in the UI. */ + events?: SubagentEvents; +} + +export async function runDispatchLoop( + db: Database.Database, + req: ChatRequest, + workspacePath: string, + cfg: DispatchConfig, + getWin?: () => BrowserWindow | null, + opts?: DispatchOptions, +): Promise { + const writeTools = opts?.writeTools ?? WRITE_TOOL_NAMES; + const researchTools = opts?.researchTools ?? RESEARCH_TOOL_NAMES; + const argMutator = opts?.argMutator; + const events = opts?.events; + let subSeq = 0; + const date = new Date().toLocaleDateString("en-US", { + weekday: "long", year: "numeric", month: "long", day: "numeric", + }); + + const metrics: SubagentMetrics = { + promptTokens: 0, completionTokens: 0, reasoningTokens: 0, + dispatcherPromptTokens: 0, dispatcherCompletionTokens: 0, dispatcherReasoningTokens: 0, + toolCalls: 0, toolErrors: 0, subagentRuns: 0, + writeInvocations: 0, researchInvocations: 0, + }; + + const messages: OpenAIMessage[] = [ + { role: "system", content: DISPATCH_SYSTEM_PROMPT(date) }, + ...(req.history ?? []).map((m) => { + const out: OpenAIMessage = { role: m.role, content: m.content }; + if (m.tool_calls) out.tool_calls = m.tool_calls; + if (m.tool_call_id) out.tool_call_id = m.tool_call_id; + if (m.name) out.name = m.name; + return out; + }), + { role: "user", content: req.message }, + ]; + + const maxSteps = req.config?.maxSteps ?? 12; + const temperature = req.config?.temperature ?? 0.3; + let finalContent = ""; + let finalReasoning = ""; + + for (let round = 0; round < maxSteps; round++) { + const headers: Record = { "Content-Type": "application/json" }; + if (cfg.apiKey) headers["Authorization"] = `Bearer ${cfg.apiKey}`; + + const response = await fetch(`${cfg.baseUrl}/v1/chat/completions`, { + method: "POST", + headers, + body: JSON.stringify({ + model: cfg.model, + messages, + tools: DISPATCH_TOOLS, + tool_choice: "auto", + max_tokens: 4096, + temperature, + stream: false, + stream_options: undefined, + }), + }); + + if (!response.ok) { + const errText = await response.text().catch(() => response.statusText); + return { content: `Dispatch endpoint error (${response.status}): ${errText.slice(0, 300)}`, reasoning: finalReasoning, metrics }; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = await response.json() as any; + if (data.usage) { + const pt = data.usage.prompt_tokens ?? 0; + const ct = data.usage.completion_tokens ?? 0; + const rt = data.usage.completion_tokens_details?.reasoning_tokens ?? 0; + // Total-cost figures accumulate across all turns… + metrics.promptTokens += pt; + metrics.completionTokens += ct; + metrics.reasoningTokens += rt; + // …but the dispatcher's CONTEXT is the latest turn's prompt size (system + // prompt + subagent briefs fed back), NOT a running sum. This is what the + // main/total ContextRing should reflect — the dispatcher never holds the + // raw tool outputs the subagents processed. + metrics.dispatcherPromptTokens = pt; + metrics.dispatcherCompletionTokens += ct; + metrics.dispatcherReasoningTokens += rt; + } + + const choice = data.choices?.[0]; + const assistantMsg = choice?.message as OpenAIMessage & { reasoning?: string } | undefined; + if (!assistantMsg) { + return { content: "No response from dispatch model.", reasoning: finalReasoning, metrics }; + } + if (assistantMsg.reasoning) finalReasoning += assistantMsg.reasoning; + + // Strip reasoning before pushing back into history. + const { reasoning: _r, ...msgClean } = assistantMsg; + + if (!msgClean.tool_calls?.length) { + finalContent = (msgClean.content ?? "").trim(); + messages.push(msgClean); + // Weak models can end the dispatch turn with empty content even after + // subagents did real work. Force a final synthesis from the gathered + // subagent results rather than returning nothing. + if (!finalContent && metrics.subagentRuns > 0) { + finalContent = (await forceFinalAnswer(cfg, messages, metrics)).trim(); + } + return { content: finalContent, reasoning: finalReasoning, metrics }; + } + + messages.push(msgClean); + for (const call of msgClean.tool_calls) { + metrics.toolCalls += 1; + let args: { instruction?: string } = {}; + try { args = JSON.parse(call.function.arguments || "{}"); } catch { /* keep empty */ } + const instruction = args.instruction ?? ""; + + let subResult: string; + if (call.function.name === "research") { + metrics.researchInvocations += 1; + const childId = `${req.threadId}:sub:${subSeq++}`; + events?.onSubagentStart?.({ childId, role: "research", instruction }); + subResult = await runSubagent( + db, req, workspacePath, cfg, RESEARCH_SYSTEM_PROMPT(date), + instruction, researchTools, metrics, getWin, argMutator, childId, events, + ); + events?.onSubagentDone?.({ childId, role: "research", result: subResult }); + } else if (call.function.name === "write") { + metrics.writeInvocations += 1; + const childId = `${req.threadId}:sub:${subSeq++}`; + events?.onSubagentStart?.({ childId, role: "write", instruction }); + subResult = await runSubagent( + db, req, workspacePath, cfg, WRITE_SYSTEM_PROMPT(date), + instruction, writeTools, metrics, getWin, argMutator, childId, events, + ); + events?.onSubagentDone?.({ childId, role: "write", result: subResult }); + } else { + subResult = JSON.stringify({ error: `Unknown dispatch tool: ${call.function.name}` }); + metrics.toolErrors += 1; + } + + messages.push({ role: "tool", tool_call_id: call.id, content: subResult }); + } + } + + return { + content: finalContent || "Dispatcher reached the maximum number of steps.", + reasoning: finalReasoning, + metrics, + }; +} + +// Reference (keeps RunToolLoopResult imported for type parity with chat-loop). +export type { RunToolLoopResult }; diff --git a/electron/lib/chat-subagent-stream.test.ts b/electron/lib/chat-subagent-stream.test.ts new file mode 100644 index 00000000..7a1d7a61 --- /dev/null +++ b/electron/lib/chat-subagent-stream.test.ts @@ -0,0 +1,96 @@ +/** + * Live smoke test for the subagent chat streaming path. + * + * Verifies runDispatchLoop emits the SubagentEvents the chat:stream handler + * relies on (start/done + token/tool-call), so the renderer's expandable trace + * has data to render. Gated on a reachable endpoint + CAIRN_SKIP_LIVE_TESTS. + * + * Run: + * TEST_LLM_BASE_URL=... TEST_LLM_MODEL=... TEST_LLM_API_KEY=... \ + * npx vitest run --project node electron/lib/chat-subagent-stream.test.ts + */ + +import { describe, it, beforeEach, expect } from "vitest"; +import BetterSqlite3 from "better-sqlite3"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { applySchema } from "../db/schema"; +import { createWorkspace, createProject, createColumn, createNote } from "../db/queries"; +import { normaliseBaseUrl } from "./llm"; +import { runDispatchLoop, type SubagentEvents } from "./chat-subagent-loop"; +import type { ChatRequest } from "./tools"; + +const BASE_URL = normaliseBaseUrl(process.env.TEST_LLM_BASE_URL?.trim() || "http://localhost:1234/v1"); +const MODEL = process.env.TEST_LLM_MODEL?.trim() || "gpt-4o-mini"; +const API_KEY = process.env.TEST_LLM_API_KEY?.trim() || ""; + +async function endpointUp(): Promise { + try { + const res = await fetch(`${BASE_URL}/v1/models`, { headers: API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}, signal: AbortSignal.timeout(2500) }); + return res.ok || res.status === 401 || res.status === 404; + } catch { return false; } +} + +describe.skipIf(!!process.env.CAIRN_SKIP_LIVE_TESTS)("subagent chat streaming (live smoke)", () => { + let up = false; + beforeEach(async () => { up = await endpointUp(); }); + + it("emits subagent start/done + token/tool-call events", async () => { + if (!up) { + // eslint-disable-next-line no-console + console.log(`[skip] no endpoint at ${BASE_URL}`); + return; + } + + const db = new BetterSqlite3(":memory:"); + applySchema(db); + const wp = fs.mkdtempSync(path.join(os.tmpdir(), "cairn-substream-")); + createWorkspace(db, { id: "ws", name: "WS" }); + createProject(db, { id: "p", workspaceId: "ws", name: "Cairn", priority: "high" }); + createColumn(db, { id: "c", projectId: "p", workspaceId: "ws", name: "Backlog", type: "backlog", order: 0 }); + createNote(db, { id: "n1", projectId: "p", workspaceId: "ws", title: "Alpha note", content: "The alpha initiative covers onboarding and retention." }); + createNote(db, { id: "n2", projectId: "p", workspaceId: "ws", title: "Beta note", content: "Beta is about performance and payload size." }); + + const starts: string[] = []; + const dones: string[] = []; + let tokenCount = 0; + const subUsage = new Map(); + + const events: SubagentEvents = { + onSubagentStart: (e) => { starts.push(`${e.role}:${e.childId}`); }, + onSubagentDone: (e) => { dones.push(`${e.role}:${e.childId}`); }, + onSubagentToken: () => { tokenCount++; }, + onSubagentUsage: (e) => { subUsage.set(e.childId, e.promptTokens); }, + }; + + const req: ChatRequest = { + message: "Research the notes in this project and summarise the main themes in your reply.", + threadId: "t1", workspaceId: "ws", projectId: "p", + config: { maxSteps: 12, temperature: 0.2 }, + }; + + const result = await runDispatchLoop(db, req, wp, { baseUrl: BASE_URL, model: MODEL, apiKey: API_KEY, provider: "openai" }, undefined, { events }); + db.close(); + + const m = result.metrics; + // eslint-disable-next-line no-console + console.log(`starts=${starts.length} dones=${dones.length} tokens=${tokenCount} finalChars=${result.content.length}`); + // eslint-disable-next-line no-console + console.log(`subagent roles: ${starts.join(", ")}`); + // eslint-disable-next-line no-console + console.log(`TOTAL promptTokens=${m.promptTokens} DISPATCHER context=${m.dispatcherPromptTokens} perSubagentUsage=${[...subUsage.values()].join(",")}`); + + // At least one subagent must have started + finished, and produced a final reply. + expect(starts.length).toBeGreaterThanOrEqual(1); + expect(dones.length).toBe(starts.length); + expect(result.content.length).toBeGreaterThan(0); + // Per-subagent usage must be reported (one entry per subagent that ran) so + // each subagent can render its own ring. + expect(subUsage.size).toBeGreaterThanOrEqual(1); + // The dispatcher's context (its ring) must be SMALLER than the summed total — + // that's the whole point: it holds only briefs, not the raw tool outputs. + expect(m.dispatcherPromptTokens).toBeLessThan(m.promptTokens); + expect(m.dispatcherPromptTokens).toBeGreaterThan(0); + }, 300_000); +}); diff --git a/electron/lib/chat-write-recovery-benchmark.test.ts b/electron/lib/chat-write-recovery-benchmark.test.ts new file mode 100644 index 00000000..47ce4495 --- /dev/null +++ b/electron/lib/chat-write-recovery-benchmark.test.ts @@ -0,0 +1,240 @@ +/** + * Cairn — Fault-injection benchmark: write-failure recovery + * + * Answers the design question: when the writer provides a wrong `oldString` to + * patch_note, does the subagent architecture PING-PONG back to the dispatcher, + * and does a "pure write-only" writer (no get_note) get stuck? + * + * We force the FIRST patch_note call to miss (corrupt its oldString) via the + * argMutator seam on runToolLoop / runDispatchLoop, then measure recovery under + * three configs: + * + * A. STRICT subagent — writer has NO read tools (WRITE_TOOL_NAMES_STRICT). + * It cannot get_note to self-correct → failure should escalate to the + * dispatcher (writeInvocations > 1 = ping-pong) or fail outright. + * B. HYBRID subagent — writer includes get_note (WRITE_TOOL_NAMES, default). + * It should self-correct inside its own loop (writeInvocations == 1). + * C. SINGLE agent — control; self-corrects in one context. + * + * "Recovered" = the note actually contains the intended new text at the end. + * + * Endpoint via the repo convention (.env.test): TEST_LLM_BASE_URL / _MODEL / _API_KEY. + * Gated by CAIRN_SKIP_LIVE_TESTS and endpoint reachability. + * + * Run: + * npx vitest run --project node electron/lib/chat-write-recovery-benchmark.test.ts + */ + +import { describe, it, beforeEach, expect } from "vitest"; +import type Database from "better-sqlite3"; +import BetterSqlite3 from "better-sqlite3"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { applySchema } from "../db/schema"; +import { createWorkspace, createProject, createColumn, createNote, getNoteById } from "../db/queries"; +import { buildSystemPrompt, type ChatRequest } from "./tools"; +import { normaliseBaseUrl } from "./llm"; +import { runToolLoop } from "./chat-loop"; +import { runDispatchLoop, WRITE_TOOL_NAMES, WRITE_TOOL_NAMES_STRICT } from "./chat-subagent-loop"; + +// For THIS test we drop append_to_note from the writer sets so the model can't +// sidestep the patch_note path (an in-place replacement is required). This +// isolates the failure mode under study: a bad oldString on patch_note. +const noAppend = (s: ReadonlySet) => new Set([...s].filter((n) => n !== "append_to_note")); +const HYBRID_WRITE = noAppend(WRITE_TOOL_NAMES); // still has get_note +const STRICT_WRITE = noAppend(WRITE_TOOL_NAMES_STRICT); // no read tools at all + +const BASE_URL = normaliseBaseUrl(process.env.TEST_LLM_BASE_URL?.trim() || "http://localhost:1234/v1"); +const MODEL = process.env.TEST_LLM_MODEL?.trim() || "gpt-4o-mini"; +const API_KEY = process.env.TEST_LLM_API_KEY?.trim() || ""; + +const WS = "ws-fault"; +const PROJ = "proj-fault"; +const COL = "col-fault"; +const NOTE_ID = "n-themes"; +const NOTE_TITLE = "Themes Synthesis"; + +// A note with a distinctive, easy-to-target anchor line so the correct oldString +// is unambiguous once the model reads the real content. +const NOTE_BODY = `# Themes Synthesis + +## Overview +This project spans mobile semantic search, code indexing, and performance work. + +## Key Themes +- On-device semantic search (NLContextualEmbedding) +- Chunked code indexing (jina-code) +- Payload/token optimization + +## Open Questions +- Should the semantic score floor be tuned per-project? +`; + +// The intended edit is an IN-PLACE REPLACEMENT (not an append), so the model +// must use patch_note with a correct oldString — this is the operation whose +// failure we want to test. The natural target is the Overview sentence. +const ANCHOR = "This project spans mobile semantic search, code indexing, and performance work."; + +function makeDb() { + const db = new BetterSqlite3(":memory:"); + applySchema(db); + return db; +} + +function seed(db: Database.Database) { + createWorkspace(db, { id: WS, name: "Fault WS" }); + createProject(db, { id: PROJ, workspaceId: WS, name: "Cairn", priority: "high" }); + createColumn(db, { id: COL, projectId: PROJ, workspaceId: WS, name: "Backlog", type: "backlog", order: 0 }); + createNote(db, { id: NOTE_ID, projectId: PROJ, workspaceId: WS, title: NOTE_TITLE, content: NOTE_BODY }); +} + +async function endpointUp(): Promise { + try { + const res = await fetch(`${BASE_URL}/v1/models`, { + headers: API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}, + signal: AbortSignal.timeout(2500), + }); + return res.ok || res.status === 401 || res.status === 404; + } catch { return false; } +} + +/** + * Fault injector factory. Corrupts the FIRST patch_note call's oldString so it + * cannot match, then passes everything else through untouched — simulating the + * model guessing the wrong replacement text once. + */ +function makeFirstPatchFault() { + let fired = false; + const injector = (name: string, args: Record): Record => { + if (!fired && name === "patch_note" && typeof args.oldString === "string") { + fired = true; + return { ...args, oldString: args.oldString + " __NONEXISTENT_ZZZ__" }; + } + return args; + }; + return { injector, didFire: () => fired }; +} + +function recovered(db: Database.Database): boolean { + const note = getNoteById(db, NOTE_ID) as { content?: string } | undefined; + const content = note?.content ?? ""; + // Recovery = the Overview sentence was actually rewritten (the anchor text is + // GONE and the new phrase is present), i.e. a targeted patch_note landed. + return !content.includes(ANCHOR) && content.toLowerCase().includes("throughline"); +} + +const PROMPT = `In the note "${NOTE_TITLE}", REPLACE the sentence in the Overview section that begins "This project spans..." with a new sentence stating that the project's throughline is on-device intelligence under tight token budgets. You must edit that existing sentence in place using patch_note (do not append a new section).`; + +interface RecoveryRecord { + config: string; + recovered: boolean; + promptTokens: number; + completionTokens: number; + latencyMs: number; + toolCalls: number; + toolErrors: number; + writeInvocations: number; + faultFired: boolean; +} + +function baseReq(): ChatRequest { + return { + message: PROMPT, threadId: "fault-thread", + workspaceId: WS, projectId: PROJ, + config: { maxSteps: 12, temperature: 0.2 }, + }; +} + +async function runSingle(db: Database.Database, wp: string): Promise { + const req = baseReq(); + const messages = [ + { role: "system" as const, content: buildSystemPrompt(req) }, + { role: "user" as const, content: PROMPT }, + ]; + const fault = makeFirstPatchFault(); + let pt = 0, ct = 0, calls = 0, errs = 0; + const t0 = Date.now(); + await runToolLoop( + db, req, wp, BASE_URL, MODEL, API_KEY, messages, + () => { calls += 1; }, undefined, undefined, "openai", + (p, c) => { pt += p; ct += c; }, + (e) => { if (e.output) { try { if (JSON.parse(e.output)?.error) errs += 1; } catch { /* */ } } }, + undefined, undefined, [], undefined, fault.injector, + ); + return { + config: "C-single", recovered: recovered(db), + promptTokens: pt, completionTokens: ct, latencyMs: Date.now() - t0, + toolCalls: calls, toolErrors: errs, writeInvocations: 0, faultFired: fault.didFire(), + }; +} + +async function runSub(db: Database.Database, wp: string, label: string, writeTools: ReadonlySet): Promise { + const req = baseReq(); + const fault = makeFirstPatchFault(); + const t0 = Date.now(); + const { metrics } = await runDispatchLoop( + db, req, wp, { baseUrl: BASE_URL, model: MODEL, apiKey: API_KEY, provider: "openai" }, + undefined, { writeTools, argMutator: fault.injector }, + ); + return { + config: label, recovered: recovered(db), + promptTokens: metrics.promptTokens, completionTokens: metrics.completionTokens, + latencyMs: Date.now() - t0, toolCalls: metrics.toolCalls, toolErrors: metrics.toolErrors, + writeInvocations: metrics.writeInvocations, faultFired: fault.didFire(), + }; +} + +describe.skipIf(!!process.env.CAIRN_SKIP_LIVE_TESTS)("write-failure recovery (fault injection, live)", () => { + let up = false; + beforeEach(async () => { up = await endpointUp(); }); + + it("compares strict-write vs hybrid-write vs single on a forced bad oldString", async () => { + if (!up) { + // eslint-disable-next-line no-console + console.log(`[skip] No LLM endpoint at ${BASE_URL}. Set TEST_LLM_* to run.`); + return; + } + + const wp = fs.mkdtempSync(path.join(os.tmpdir(), "cairn-fault-")); + const rows: RecoveryRecord[] = []; + + // A — strict write-only (no get_note) + { + const db = makeDb(); seed(db); + try { rows.push(await runSub(db, wp, "A-strict", STRICT_WRITE)); } finally { db.close(); } + } + // B — hybrid write (with get_note) + { + const db = makeDb(); seed(db); + try { rows.push(await runSub(db, wp, "B-hybrid", HYBRID_WRITE)); } finally { db.close(); } + } + // C — single agent + { + const db = makeDb(); seed(db); + try { rows.push(await runSingle(db, wp)); } finally { db.close(); } + } + + for (const r of rows) { + // eslint-disable-next-line no-console + console.log( + `${r.config.padEnd(9)} recovered=${r.recovered ? "YES" : "NO "} ` + + `faultFired=${r.faultFired ? "y" : "n"} ptok=${String(r.promptTokens).padStart(6)} ` + + `ctok=${String(r.completionTokens).padStart(5)} ${String(r.latencyMs).padStart(6)}ms ` + + `calls=${r.toolCalls} err=${r.toolErrors} writeInvocations=${r.writeInvocations}`, + ); + } + + const header = "config,recovered,faultFired,promptTok,completionTok,latencyMs,toolCalls,toolErrors,writeInvocations"; + const csv = [header, ...rows.map((r) => + [r.config, r.recovered, r.faultFired, r.promptTokens, r.completionTokens, r.latencyMs, r.toolCalls, r.toolErrors, r.writeInvocations].join(","))].join("\n"); + const outPath = path.join(wp, "write-recovery.csv"); + fs.writeFileSync(outPath, csv); + // eslint-disable-next-line no-console + console.log("\n=== CSV ===\n" + csv + "\n\nWritten to: " + outPath); + + // faultFired / recovered / writeInvocations are the FINDINGS, logged above. + // The test only asserts all three configs ran; interpretation is manual. + expect(rows.length).toBe(3); + }, 600_000); +}); diff --git a/electron/lib/tools.ts b/electron/lib/tools.ts index 911036f8..6628e69c 100644 --- a/electron/lib/tools.ts +++ b/electron/lib/tools.ts @@ -145,6 +145,8 @@ export interface ChatRequest { config?: { baseUrl?: string; model?: string; apiKey?: string; maxSteps?: number; temperature?: number }; systemPrompt?: string; images?: Array<{ name: string; dataUrl: string }>; + /** When true, run the dispatch → research/write subagent loop instead of the single-agent tool loop. */ + useSubagents?: boolean; } export function buildSystemPrompt(req: ChatRequest): string { diff --git a/electron/preload.ts b/electron/preload.ts index 86bc7a91..9ea5d83f 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -226,6 +226,43 @@ const api = { ipcRenderer.on("chat:usage", handler); return () => ipcRenderer.off("chat:usage", handler); }, + // ── Subagent mode (dispatch → research/write) live trace ──────────────── + onSubagent: (cb: (e: { childId: string; role: string; instruction?: string; result?: string; status: "start" | "done"; threadId?: string }) => void) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handler = (_: any, e: any) => cb(e); + ipcRenderer.on("chat:subagent", handler); + return () => ipcRenderer.off("chat:subagent", handler); + }, + onSubagentToken: (cb: (e: { childId: string; delta: string; threadId?: string }) => void) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handler = (_: any, e: any) => cb(e); + ipcRenderer.on("chat:subagent-token", handler); + return () => ipcRenderer.off("chat:subagent-token", handler); + }, + onSubagentThought: (cb: (e: { childId: string; delta: string; threadId?: string }) => void) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handler = (_: any, e: any) => cb(e); + ipcRenderer.on("chat:subagent-thought", handler); + return () => ipcRenderer.off("chat:subagent-thought", handler); + }, + onSubagentToolCall: (cb: (e: { childId: string; tool: string; label: string; args: Record; callId?: string; threadId?: string }) => void) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handler = (_: any, e: any) => cb(e); + ipcRenderer.on("chat:subagent-tool-call", handler); + return () => ipcRenderer.off("chat:subagent-tool-call", handler); + }, + onSubagentToolCallDone: (cb: (e: { childId: string; tool: string; cairnRef?: { type: "note" | "task"; id: string; title: string }; output?: string; callId?: string; threadId?: string }) => void) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handler = (_: any, e: any) => cb(e); + ipcRenderer.on("chat:subagent-tool-call-done", handler); + return () => ipcRenderer.off("chat:subagent-tool-call-done", handler); + }, + onSubagentUsage: (cb: (e: { childId: string; promptTokens: number; completionTokens: number; reasoningTokens?: number; threadId?: string }) => void) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handler = (_: any, e: any) => cb(e); + ipcRenderer.on("chat:subagent-usage", handler); + return () => ipcRenderer.off("chat:subagent-usage", handler); + }, // ── Pop-out window ────────────────────────── /** Called by main window: sends current chat state, triggers window creation. */ popOut: (payload: { diff --git a/electron/shared/db-mappers.ts b/electron/shared/db-mappers.ts index f9fefcca..021ea577 100644 --- a/electron/shared/db-mappers.ts +++ b/electron/shared/db-mappers.ts @@ -218,6 +218,7 @@ export function toChatThread(row: any) { workspaceId: row.workspace_id as string, projectId: row.project_id as string | undefined, title: row.title as string | undefined, + useSubagents: row.use_subagents ? true : false, createdAt: row.created_at as string, updatedAt: row.updated_at as string, }; @@ -232,6 +233,7 @@ export function toChatMessage(row: any) { reasoning: (row.reasoning as string | null) ?? undefined, contextRefs: row.context_refs ? JSON.parse(row.context_refs) : undefined, toolCalls: row.tool_calls ? JSON.parse(row.tool_calls) : undefined, + subagents: row.subagents ? JSON.parse(row.subagents) : undefined, createdAt: row.created_at as string, }; } diff --git a/src/components/chat/chat-panel/ChatMessageBubble.tsx b/src/components/chat/chat-panel/ChatMessageBubble.tsx index e00327de..d79fa0ae 100644 --- a/src/components/chat/chat-panel/ChatMessageBubble.tsx +++ b/src/components/chat/chat-panel/ChatMessageBubble.tsx @@ -7,6 +7,7 @@ import { cn, formatRelative, prettifyToolLabel } from "@/lib/utils"; import { MarkdownContent } from "./MarkdownContent"; import { ActionsList } from "./ActionsList"; import { ThinkingPanel } from "./ThinkingPanel"; +import { ChatSubagentBlock } from "./ChatSubagentBlock"; import { MessageAvatar } from "./message-ui"; import { CairnRefChip } from "@/components/shared/cairn-ref-chip"; import type { ChatMessage, LinkedContextReference, ChatToolCallRecord } from "@/types"; @@ -60,6 +61,13 @@ export const ChatMessageBubble = React.memo(function ChatMessageBubble({ message
+ {!isUser && message.subagents && message.subagents.length > 0 && ( +
+ {message.subagents.map((sub) => ( + + ))} +
+ )} {!isUser && message.toolCalls && message.toolCalls.length > 0 && (
{message.toolCalls.map((tc, i) => ( diff --git a/src/components/chat/chat-panel/ChatSubagentBlock.tsx b/src/components/chat/chat-panel/ChatSubagentBlock.tsx new file mode 100644 index 00000000..e532a1b7 --- /dev/null +++ b/src/components/chat/chat-panel/ChatSubagentBlock.tsx @@ -0,0 +1,103 @@ +"use client"; + +/** + * ChatSubagentBlock — expandable inline trace of a subagent-mode run. + * + * Renders one dispatch → research/write subagent: its role, the dispatcher's + * instruction, a live/streamed content brief, its tool-call chips, and the + * result returned to the dispatcher. Collapsed by default; expand to "step into" + * what the subagent did. Used both live (during streaming) and persisted. + */ + +import React, { useState } from "react"; +import { Loader2, GitBranch, Search, PencilLine, ChevronDown, ChevronRight, CheckCircle } from "lucide-react"; +import { prettifyToolLabel } from "@/lib/utils"; +import { useCairnStore } from "@/store"; +import { MarkdownContent } from "./MarkdownContent"; +import { ContextRing } from "@/components/agent/ContextRing"; +import { CairnRefChip } from "@/components/shared/cairn-ref-chip"; +import type { ChatSubagent, ChatToolCallRecord } from "@/types"; + +function SubToolChip({ tc }: { tc: ChatToolCallRecord }) { + if (tc.cairnRef) return ; + const running = !tc.output; + return ( +
+ {running + ? + : } + {prettifyToolLabel(tc.label)} +
+ ); +} + +export function ChatSubagentBlock({ sub }: { sub: ChatSubagent }) { + const [expanded, setExpanded] = useState(false); + const contextLimit = useCairnStore((s) => s.aiConfig.contextLimit ?? 128000); + const roleLabel = sub.role === "research" ? "Research agent" : sub.role === "write" ? "Writing agent" : "Sub-agent"; + const RoleIcon = sub.role === "research" ? Search : sub.role === "write" ? PencilLine : GitBranch; + + return ( +
+ + + {expanded && ( +
+ {sub.instruction && ( +
+

Instruction

+

{sub.instruction}

+
+ )} + {(sub.toolCalls?.length ?? 0) > 0 && ( +
+ {sub.toolCalls!.map((tc, i) => )} +
+ )} + {sub.content && ( +
+

+ {sub.role === "write" ? "Confirmation" : "Findings brief"} +

+
+ +
+
+ )} + {!sub.content && sub.running && ( +
+ + Working… +
+ )} +
+ )} +
+ ); +} diff --git a/src/components/chat/chat-panel/index.tsx b/src/components/chat/chat-panel/index.tsx index 49598180..7fb1b7a4 100644 --- a/src/components/chat/chat-panel/index.tsx +++ b/src/components/chat/chat-panel/index.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useState, useRef, useEffect, useMemo, useCallback } from "react"; -import { Trash2, ChevronDown, ArrowLeftFromLine } from "lucide-react"; +import { Trash2, ChevronDown, ArrowLeftFromLine, GitBranch } from "lucide-react"; import { useCairnStore } from "@/store"; import { useShallow } from "zustand/react/shallow"; import { useChatStream } from "@/hooks/useChatStream"; @@ -13,6 +13,7 @@ import type { ChatHistoryEntry } from "@/types"; import { Tooltip } from "@/components/ui/tooltip"; import { ChatMessageBubble } from "./ChatMessageBubble"; +import { ChatSubagentBlock } from "./ChatSubagentBlock"; import { SuggestedPrompts } from "./SuggestedPrompts"; import { ToolCallIndicator } from "./ToolCallIndicator"; import { QuestionForm } from "./QuestionForm"; @@ -127,7 +128,8 @@ export function ChatPanel({ prefill, onPrefillConsumed, popoutMode }: ChatPanelP const projectRef = useRef(null); const [projectOpen, setProjectOpen] = useState(false); - const { isLoading, toolCalls, streamingContent, streamingThought, pendingQuestions, sendStream, stopStream } = useChatStream(threadId); + const { isLoading, toolCalls, streamingContent, streamingThought, subagents, pendingQuestions, sendStream, stopStream } = useChatStream(threadId); + const setThreadUseSubagents = useCairnStore((s) => s.setThreadUseSubagents); const project = useMemo(() => projects.find((p) => p.id === activeProjectId), [projects, activeProjectId]); const workspace = useMemo(() => workspaces.find((w) => w.id === activeWorkspaceId), [workspaces, activeWorkspaceId]); @@ -468,8 +470,9 @@ export function ChatPanel({ prefill, onPrefillConsumed, popoutMode }: ChatPanelP }, systemPrompt, images: imagesToSend?.map((img) => ({ name: img.name, dataUrl: img.dataUrl })), + useSubagents: activeThread?.useSubagents ?? false, }); - }, [input, threadId, addMessage, sendStream, activeProjectId, activeWorkspaceId, messages, aiConfig, activeView, graphData, selectedNode, handleArchiveChat, project, pendingImages]); + }, [input, threadId, addMessage, sendStream, activeProjectId, activeWorkspaceId, messages, aiConfig, activeView, graphData, selectedNode, handleArchiveChat, project, pendingImages, activeThread]); const handleRetry = useCallback((content: string) => { handleSend(content); @@ -570,6 +573,30 @@ export function ChatPanel({ prefill, onPrefillConsumed, popoutMode }: ChatPanelP /> )} + {threadId && aiConfig.provider !== "localllm" && ( + + + + )} + {messages.length > 0 && ( @@ -617,6 +644,13 @@ export function ChatPanel({ prefill, onPrefillConsumed, popoutMode }: ChatPanelP disabled={isLoading && !pendingQuestions} /> )} + {isLoading && subagents.length > 0 && ( +
+ {subagents.map((sub) => ( + + ))} +
+ )} {isLoading && }
diff --git a/src/hooks/useChatStream.ts b/src/hooks/useChatStream.ts index 495c02ef..d2f6a457 100644 --- a/src/hooks/useChatStream.ts +++ b/src/hooks/useChatStream.ts @@ -18,7 +18,7 @@ import { useState, useEffect, useRef } from "react"; import { useCairnStore } from "@/store"; -import type { SuggestedAction, TokenBreakdown, ChatHistoryEntry } from "@/types"; +import type { SuggestedAction, TokenBreakdown, ChatHistoryEntry, ChatSubagent } from "@/types"; export interface ChatToolCall { tool: string; @@ -54,6 +54,8 @@ export interface ChatStreamRequest { systemPrompt?: string; /** Images attached to the current user message (base64 data URLs) */ images?: Array<{ name: string; dataUrl: string }>; + /** Route this turn through the dispatch → research/write subagent loop. */ + useSubagents?: boolean; } export interface UseChatStreamResult { @@ -62,6 +64,8 @@ export interface UseChatStreamResult { streamingContent: string; /** Reasoning / thinking text streamed live from the model. Cleared on done. */ streamingThought: string; + /** Live subagent traces for the current turn (subagent mode). Cleared on done. */ + subagents: ChatSubagent[]; /** Non-null when the agent has called ask_questions — cleared on submit or done. */ pendingQuestions: PendingQuestion[] | null; sendStream: (req: ChatStreamRequest) => void; @@ -78,6 +82,7 @@ export function useChatStream(threadId: string | null): UseChatStreamResult { const [streamingContent, setStreamingContent] = useState(""); const [streamingThought, setStreamingThought] = useState(""); const [pendingQuestions, setPendingQuestions] = useState(null); + const [subagents, setSubagents] = useState([]); const threadIdRef = useRef(null); // Accumulates tool calls for the current turn so they can be persisted on done. @@ -85,6 +90,8 @@ export function useChatStream(threadId: string | null): UseChatStreamResult { // needing to be in the dependency array. const toolCallsRef = useRef([]); const pendingActionsRef = useRef([]); + // Subagent traces for the current turn (parallel ref so onDone can persist them). + const subagentsRef = useRef([]); useEffect(() => { threadIdRef.current = threadId; }, [threadId]); @@ -159,6 +166,72 @@ export function useChatStream(threadId: string | null): UseChatStreamResult { setStreamingThought((prev) => prev + e.delta); }); + // ── Subagent live trace (subagent mode) ────────────────────────────────── + const mutateSub = (childId: string, fn: (s: ChatSubagent) => ChatSubagent) => { + setSubagents((prev) => { + const next = prev.map((s) => (s.childId === childId ? fn(s) : s)); + subagentsRef.current = next; + return next; + }); + }; + + const unsubSub = electron.chat.onSubagent?.((e) => { + if (!isForThisThread(e)) return; + if (e.status === "start") { + setSubagents((prev) => { + if (prev.some((s) => s.childId === e.childId)) return prev; + const next = [...prev, { + childId: e.childId, role: e.role, instruction: e.instruction ?? "", + content: "", toolCalls: [], running: true, + } as ChatSubagent]; + subagentsRef.current = next; + return next; + }); + } else { + mutateSub(e.childId, (s) => ({ ...s, running: false, result: e.result ?? s.content })); + } + }); + + const unsubSubToken = electron.chat.onSubagentToken?.((e) => { + if (!isForThisThread(e)) return; + mutateSub(e.childId, (s) => ({ ...s, content: s.content + e.delta })); + }); + + const unsubSubThought = electron.chat.onSubagentThought?.((e) => { + if (!isForThisThread(e)) return; + mutateSub(e.childId, (s) => ({ ...s, reasoning: (s.reasoning ?? "") + e.delta })); + }); + + const unsubSubTool = electron.chat.onSubagentToolCall?.((e) => { + if (!isForThisThread(e)) return; + mutateSub(e.childId, (s) => ({ + ...s, + toolCalls: [...(s.toolCalls ?? []), { tool: e.tool, label: e.label, callId: e.callId, args: e.args ? JSON.stringify(e.args) : undefined }], + })); + }); + + const unsubSubToolDone = electron.chat.onSubagentToolCallDone?.((e) => { + if (!isForThisThread(e)) return; + mutateSub(e.childId, (s) => { + const tcs = [...(s.toolCalls ?? [])]; + let idx = e.callId ? tcs.findIndex((t) => t.callId === e.callId) : -1; + if (idx === -1) { + const rev = [...tcs].reverse().findIndex((t) => t.tool === e.tool); + if (rev !== -1) idx = tcs.length - 1 - rev; + } + if (idx !== -1) tcs[idx] = { ...tcs[idx], cairnRef: e.cairnRef, output: e.output }; + return { ...s, toolCalls: tcs }; + }); + }); + + const unsubSubUsage = electron.chat.onSubagentUsage?.((e) => { + if (!isForThisThread(e)) return; + mutateSub(e.childId, (s) => ({ + ...s, + lastUsage: { promptTokens: e.promptTokens, completionTokens: e.completionTokens, reasoningTokens: e.reasoningTokens }, + })); + }); + const unsubDone = (electron.chat.onDone as (cb: (e: { content: string; reasoning?: string; contextRefs: unknown[]; error?: string; threadId?: string; usage?: { promptTokens: number; completionTokens: number; reasoningTokens?: number; breakdown?: TokenBreakdown } }) => void) => () => void)((e) => { if (!isForThisThread(e)) return; const tid = threadIdRef.current; @@ -169,8 +242,9 @@ export function useChatStream(threadId: string | null): UseChatStreamResult { if (tid) { const capturedActions = pendingActionsRef.current; pendingActionsRef.current = []; + const finalSubagents = subagentsRef.current.map((s) => ({ ...s, running: false })); // eslint-disable-next-line @typescript-eslint/no-explicit-any - addMessage(tid, "assistant", e.content, e.contextRefs as any, finalToolCalls, capturedActions, e.reasoning); + addMessage(tid, "assistant", e.content, e.contextRefs as any, finalToolCalls, capturedActions, e.reasoning, undefined, finalSubagents); if (e.usage) { setThreadUsage(tid, e.usage); } @@ -181,6 +255,8 @@ export function useChatStream(threadId: string | null): UseChatStreamResult { setToolCalls([]); toolCallsRef.current = []; pendingActionsRef.current = []; + setSubagents([]); + subagentsRef.current = []; // Do NOT clear pendingQuestions here — the form must stay visible until // the user submits their answers. It is cleared in sendStream() instead. @@ -217,6 +293,12 @@ export function useChatStream(threadId: string | null): UseChatStreamResult { unsubToolDone?.(); unsubToken(); unsubThought?.(); + unsubSub?.(); + unsubSubToken?.(); + unsubSubThought?.(); + unsubSubTool?.(); + unsubSubToolDone?.(); + unsubSubUsage?.(); unsubDone(); unsubUsage(); }; @@ -229,6 +311,8 @@ export function useChatStream(threadId: string | null): UseChatStreamResult { setToolCalls([]); toolCallsRef.current = []; pendingActionsRef.current = []; + setSubagents([]); + subagentsRef.current = []; setPendingQuestions(null); if (threadId) { setThreadUsage(threadId, undefined); @@ -243,6 +327,8 @@ export function useChatStream(threadId: string | null): UseChatStreamResult { setToolCalls([]); toolCallsRef.current = []; pendingActionsRef.current = []; + setSubagents([]); + subagentsRef.current = []; setStreamingContent(""); setStreamingThought(""); // Keep pendingQuestions — user may still want to answer after stopping @@ -252,5 +338,5 @@ export function useChatStream(threadId: string | null): UseChatStreamResult { setPendingQuestions(null); } - return { isLoading, toolCalls, streamingContent, streamingThought, pendingQuestions, sendStream, stopStream, clearQuestions }; + return { isLoading, toolCalls, streamingContent, streamingThought, subagents, pendingQuestions, sendStream, stopStream, clearQuestions }; } diff --git a/src/store/slices/chat.ts b/src/store/slices/chat.ts index 4c93350d..43bbb42d 100644 --- a/src/store/slices/chat.ts +++ b/src/store/slices/chat.ts @@ -26,10 +26,12 @@ export interface ChatSlice { actions?: ChatMessage["actions"], reasoning?: string, images?: ChatMessage["images"], + subagents?: ChatMessage["subagents"], ) => ChatMessage; confirmAction: (action: PendingAction) => void; deleteThread: (threadId: ID) => void; renameThread: (threadId: ID, title: string) => void; + setThreadUseSubagents: (threadId: ID, useSubagents: boolean) => void; createNewThread: (workspaceId: ID, projectId?: ID) => ChatThread; compactChatThread: (threadId: ID) => Promise; clearThreadMessages: (threadId: ID) => Promise; @@ -77,7 +79,7 @@ export const createChatSlice: StateCreator = ( return thread; }, - addMessage(threadId, role, content, contextRefs, toolCalls, actions, reasoning, images) { + addMessage(threadId, role, content, contextRefs, toolCalls, actions, reasoning, images, subagents) { const msg: ChatMessage = { id: id(), threadId, @@ -88,6 +90,7 @@ export const createChatSlice: StateCreator = ( toolCalls: toolCalls && toolCalls.length > 0 ? toolCalls : undefined, actions: actions && actions.length > 0 ? actions : undefined, images: images && images.length > 0 ? images : undefined, + subagents: subagents && subagents.length > 0 ? subagents : undefined, createdAt: now(), }; set((s) => ({ @@ -147,6 +150,17 @@ export const createChatSlice: StateCreator = ( if (thread) ipc((e) => e.chat.upsertThread(thread)); }, + setThreadUseSubagents(threadId, useSubagents) { + set((s) => ({ + chatThreads: s.chatThreads.map((t) => + t.id === threadId ? { ...t, useSubagents, updatedAt: now() } : t + ), + })); + get().persist(); + const thread = get().chatThreads.find((t) => t.id === threadId); + if (thread) ipc((e) => e.chat.upsertThread(thread)); + }, + createNewThread(workspaceId, projectId) { const thread: ChatThread = { id: id(), diff --git a/src/types/index.ts b/src/types/index.ts index f7d14ba1..bb3d8b98 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -228,6 +228,8 @@ export interface ChatThread { title?: string; createdAt: string; updatedAt: string; + /** When true, this thread runs the dispatch → research/write subagent architecture instead of the single-agent loop. Per-thread, persisted. */ + useSubagents?: boolean; lastUsage?: { promptTokens: number; completionTokens: number; @@ -268,6 +270,33 @@ export interface ChatToolCallRecord { output?: string; // JSON output string } +/** + * A single subagent run inside a subagent-mode chat turn (dispatch → research/ + * write). Captures the subagent's role, the dispatcher's instruction, its own + * streamed content + tool calls, and the brief it returned. Rendered as an + * expandable inline block so the user can step into what each subagent did. + */ +export interface ChatSubagent { + /** Unique child id: `${threadId}:sub:` */ + childId: string; + /** "research" | "write" */ + role: string; + /** The dispatcher's instruction to this subagent */ + instruction: string; + /** Content streamed by the subagent (its findings brief / confirmation) */ + content: string; + /** Reasoning/thinking streamed by the subagent */ + reasoning?: string; + /** Tool calls the subagent made */ + toolCalls?: ChatToolCallRecord[]; + /** Whether the subagent is still running */ + running: boolean; + /** Final result returned to the dispatcher (usually == content) */ + result?: string; + /** This subagent's OWN latest context-window usage — drives its dedicated ring. */ + lastUsage?: { promptTokens: number; completionTokens: number; reasoningTokens?: number }; +} + export type SuggestedAction = | { type: "add_wikilink"; sourceNoteId: string; sourceTitle: string; targetTitle: string; reason: string } | { type: "link_note_note"; sourceNoteId: string; sourceTitle: string; targetNoteId: string; targetTitle: string; reason: string } @@ -308,6 +337,8 @@ export interface ChatMessage { actions?: SuggestedAction[]; /** Images attached to this message — inline base64 data URLs, ephemeral (not persisted to disk) */ images?: Array<{ url: string; name: string }>; + /** Subagent runs during this turn (subagent mode) — expandable inline traces. */ + subagents?: ChatSubagent[]; createdAt: string; } From 279a2d4c5044ba617b2ba8041571cc6dca519640 Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Wed, 22 Jul 2026 12:45:13 -0400 Subject: [PATCH 2/4] feat(notes,board): cross-project drag + fix live project-rename folder move - Fix: renaming a project now relocates its notes folder on disk immediately (no restart). A racing autosave could pre-create the new-slug folder, forcing renameProjectNotesDir into a merge whose collision-skip left a stale duplicate + the old folder behind until the next launch. mergeDirInto now removes same-note-id duplicates and clears the old dir (incl. OS cruft); db-handlers reconciles as a fallback when the primary move no-ops. Regression test added. - Feat: drag a folder in the notes sidebar onto another folder (or "Move to root") to reparent the whole subtree (moveFolder). - Feat: drag a folder, a single note, or a task card onto a project row in the leftmost sidebar to move it cross-project. Cards land in the target project's same-type column (fallback backlog/first). Shared cross-project-dnd payload bridges native DnD (notes) and dnd-kit (board) via a data-project-drop-id hit-test. --- changelogs/v2.5.4.md | 9 ++ electron/ipc/db-handlers.ts | 11 ++- electron/notes-files.test.ts | 26 ++++++ electron/shared/notes-io.ts | 66 +++++++++++++- src/app/globals.css | 8 ++ src/components/kanban/board.tsx | 87 +++++++++++++++++- src/components/layout/sidebar.tsx | 55 ++++++++++-- src/components/notes/notes-view.tsx | 75 ++++++++++++++-- .../notes/notes-view/FolderTreeNode.tsx | 11 ++- src/lib/cross-project-dnd.ts | 63 +++++++++++++ src/store/slices/board.test.ts | 61 +++++++++++++ src/store/slices/board.ts | 8 +- src/store/slices/notes.test.ts | 89 +++++++++++++++++++ src/store/slices/notes.ts | 88 ++++++++++++++++++ 14 files changed, 637 insertions(+), 20 deletions(-) create mode 100644 src/lib/cross-project-dnd.ts create mode 100644 src/store/slices/notes.test.ts diff --git a/changelogs/v2.5.4.md b/changelogs/v2.5.4.md index 24ec12ca..b04006b7 100644 --- a/changelogs/v2.5.4.md +++ b/changelogs/v2.5.4.md @@ -2,9 +2,18 @@ ## Added +- **Drag a notes folder into another folder.** Folders in the notes sidebar are now draggable — drop one onto another folder (or the "Move to root" zone) to reparent the whole subtree; every note inside keeps its relative structure. +- **Drag a notes folder onto another project.** Drop a folder from the notes sidebar onto a project row in the leftmost sidebar to move the entire folder (and all its notes) to that project. +- **Drag a single note onto another project.** Drop an individual note from the notes sidebar onto a project row to move just that note (its folder path is kept). +- **Drag a task card onto another project.** Drop a card from the board onto a project row in the leftmost sidebar to move it there. The card lands in the target project's column of the *same type* (e.g. In Progress → In Progress), falling back to Backlog / the first column if there's no match — so its state is preserved. - **Subagents in AI chat (per-thread toggle).** A new "Subagents" toggle in the chat toolbar routes a conversation through a dispatch → research/write architecture: a thin dispatcher delegates read-only research and write work to focused sub-agents, each with a smaller tool set. On capable models this cut prompt tokens ~43% on research-heavy tasks at equal output quality. The toggle is per-thread and persisted; hidden on the on-device Llama provider (small models are unreliable with the multi-hop split). - **Live, expandable subagent trace.** While a subagent-mode turn runs, each sub-agent appears as an inline block you can expand to "step into" it — showing its instruction, streamed findings/confirmation, and its tool calls. Traces persist in thread history. +## Fixed + +- **Renaming a project now renames its notes folder on disk immediately** — no app restart required. When an open note autosaved during the rename, the note file was written under the new folder name first, which forced a merge that left a stale duplicate (and the old, now-empty folder) behind on disk until the next launch. Stale same-note duplicates are now removed and the old folder is cleaned up as part of the rename. + + ## Internal - Refactored the in-app chat tool-call loop: extracted `runToolLoop` from `electron/ipc/chat.ts` into a shared `electron/lib/chat-loop.ts` (behaviour unchanged) so it can be reused and benchmarked. Added an optional tool-array override to support restricted tool sets, plus an optional (default no-op) pre-execution arg hook used only by tests for fault injection. diff --git a/electron/ipc/db-handlers.ts b/electron/ipc/db-handlers.ts index a79036b7..d6e73fc4 100644 --- a/electron/ipc/db-handlers.ts +++ b/electron/ipc/db-handlers.ts @@ -14,7 +14,7 @@ import path from "path"; import { registerIpcHandle, registerIpcOn } from "./registry"; import { handle, getProjectName, type DbContext } from "./result-helpers"; import * as q from "../db/queries"; -import { writeNoteFile, deleteNoteFile, deleteProjectNotesDir, renameProjectNotesDir, stripMarkdown, findNoteFilePath } from "../notes-files"; +import { writeNoteFile, deleteNoteFile, deleteProjectNotesDir, renameProjectNotesDir, reconcileProjectFolders, stripMarkdown, findNoteFilePath } from "../notes-files"; import { suppressNextChange } from "../file-watcher"; import { executeTool as executeMcpTool } from "../mcp/tools"; import { executeReadTool } from "../lib/read-tools"; @@ -151,7 +151,14 @@ export function registerDbHandlers(ctx: DbContext): void { const before = q.getProjectById(ctx.db, id); const project = q.updateProject(ctx.db, id, patch); if (before && project && before.name !== project.name) { - renameProjectNotesDir(ctx.workspacePath, before.name, project.name); + // Primary path: move the old-slug directory to the new slug directly. + const moved = renameProjectNotesDir(ctx.workspacePath, before.name, project.name); + // Self-heal fallback: if the direct move was a no-op (e.g. the project had + // no on-disk folder yet, its notes live at the vault root, or a concurrent + // write raced the rename), run the same reconciliation the app does at + // startup so any stranded old-slug folder is relocated immediately — the + // user should never have to restart for the folder to follow the rename. + if (!moved) reconcileProjectFolders(ctx.db, ctx.workspacePath); } return project; })); diff --git a/electron/notes-files.test.ts b/electron/notes-files.test.ts index b062716b..6b3a1f3a 100644 --- a/electron/notes-files.test.ts +++ b/electron/notes-files.test.ts @@ -565,8 +565,34 @@ describe("renameProjectNotesDir", () => { expect(fs.existsSync(path.join(oldDir, "From Old.md"))).toBe(false); expect(fs.existsSync(path.join(oldDir, "Existing.md"))).toBe(true); }); + + it("removes the whole old dir when a note write raced the rename (stale duplicate)", () => { + // Reproduces the live-rename bug: an autosave note write resolves the fresh + // (new) project name and writes the note under the new slug BEFORE the + // rename runs, pre-creating newDir. The merge then hit a same-name collision + // and left a stale copy behind in oldDir, so the old folder lingered on disk + // until the next app restart. Same-id duplicates must be removed instead. + const oldDir = path.join(notesDir(tmpDir), toSlug("Old")); + const newDir = path.join(notesDir(tmpDir), toSlug("New")); + fs.mkdirSync(oldDir, { recursive: true }); + fs.mkdirSync(newDir, { recursive: true }); + const noteFile = "Note A.md"; + // Same note id in both copies → the old one is a stale duplicate. + fs.writeFileSync(path.join(oldDir, noteFile), "---\nid: n1\nprojectId: p1\n---\nold body", "utf-8"); + fs.writeFileSync(path.join(newDir, noteFile), "---\nid: n1\nprojectId: p1\n---\nnew body", "utf-8"); + // A macOS cruft file that must not keep the old dir alive. + fs.writeFileSync(path.join(oldDir, ".DS_Store"), "\u0000", "utf-8"); + + const moved = renameProjectNotesDir(tmpDir, "Old", "New"); + expect(moved).toBe(true); + + // The authoritative (new) copy is untouched, and the old dir is gone. + expect(fs.readFileSync(path.join(newDir, noteFile), "utf-8")).toContain("new body"); + expect(fs.existsSync(oldDir)).toBe(false); + }); }); + describe("reconcileProjectFolders", () => { let db: Database.Database; diff --git a/electron/shared/notes-io.ts b/electron/shared/notes-io.ts index 0d3de025..42f042dc 100644 --- a/electron/shared/notes-io.ts +++ b/electron/shared/notes-io.ts @@ -290,8 +290,10 @@ export function renameProjectNotesDir( // Target exists (rare): merge children instead of overwriting, so we never // lose notes. Move each entry that doesn't collide; recurse into subdirs. mergeDirInto(oldDir, newDir); - // Remove the old dir if it's now empty (a collision may leave files behind). - try { fs.rmdirSync(oldDir); } catch { /* not empty / ignore */ } + // Remove the old dir if it's now empty. If only OS cruft remains (e.g. a + // macOS .DS_Store) and no note files are left, clear it too so the renamed + // folder doesn't linger on disk until the next app restart. + removeDirIfNoNotesLeft(oldDir); return true; } catch { return false; // best-effort — leave files where they are on failure @@ -310,10 +312,70 @@ function mergeDirInto(src: string, dst: string): void { try { fs.rmdirSync(from); } catch { /* ignore */ } } else if (!fs.existsSync(to)) { try { fs.renameSync(from, to); } catch { /* ignore collision/error */ } + } else if (isStaleDuplicate(from, to)) { + // Collision, but both files are the SAME Cairn note (same frontmatter id). + // This happens when a note write raced the rename and already wrote the + // note under the new slug — the destination copy is authoritative and the + // source copy under the old slug is a stale duplicate. Remove it so the + // old directory can be emptied (otherwise it lingers until an app restart). + try { fs.unlinkSync(from); } catch { /* ignore */ } } + // Otherwise: a genuine collision between different files — leave the source + // in place (never destroy unrelated note data). } } +/** + * True if `from` and `to` are the same Cairn note file — i.e. both parse to the + * same frontmatter `id`. Used by the merge to distinguish a stale duplicate + * (safe to delete) from a genuine name collision between two different notes. + */ +function isStaleDuplicate(from: string, to: string): boolean { + try { + if (!from.endsWith(".md") || !to.endsWith(".md")) return false; + const fromId = matter(fs.readFileSync(from, "utf-8")).data?.id; + const toId = matter(fs.readFileSync(to, "utf-8")).data?.id; + return typeof fromId === "string" && fromId.length > 0 && fromId === toId; + } catch { + return false; + } +} + +/** + * Remove `dir` if it holds no note (`.md`) files, recursively. A merge can leave + * behind OS cruft (a macOS `.DS_Store`) or now-empty subdirectories that would + * otherwise keep the renamed-away folder alive on disk. We never delete a dir + * that still contains real note data. + */ +function removeDirIfNoNotesLeft(dir: string): void { + try { + if (!fs.existsSync(dir)) return; + if (dirContainsNote(dir)) return; // real note data remains — keep it + fs.rmSync(dir, { recursive: true, force: true }); + } catch { /* best-effort */ } +} + +/** True if `dir` (recursively) contains at least one `.md` file. */ +function dirContainsNote(dir: string): boolean { + let entries: string[]; + try { + entries = fs.readdirSync(dir); + } catch { + return false; + } + for (const entry of entries) { + const full = path.join(dir, entry); + let stat: fs.Stats; + try { stat = fs.lstatSync(full); } catch { continue; } + if (stat.isDirectory()) { + if (dirContainsNote(full)) return true; + } else if (entry.endsWith(".md")) { + return true; + } + } + return false; +} + export function parseNoteFile(filePath: string): NoteFileData | null { try { const raw = fs.readFileSync(filePath, "utf-8"); diff --git a/src/app/globals.css b/src/app/globals.css index 361af89a..389d207e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -587,3 +587,11 @@ input[type="url"] { background: var(--danger); color: #fff; } + +/* Cross-project drop target: a project sidebar row highlighted while a task + card is dragged over it (from the kanban board). */ +.cairn-cross-project-drop-active { + background: color-mix(in srgb, var(--accent) 18%, transparent) !important; + outline: 1px solid var(--accent); + outline-offset: -1px; +} diff --git a/src/components/kanban/board.tsx b/src/components/kanban/board.tsx index 28087bc2..b1391d72 100644 --- a/src/components/kanban/board.tsx +++ b/src/components/kanban/board.tsx @@ -33,6 +33,45 @@ import { KanbanCard } from "./card"; import { CardDetailModal } from "./card-detail"; import type { TaskCard, BoardColumn } from "@/types"; import { getZoneHit, resolveCardDrop } from "./board-dnd"; +import { setActiveCrossProjectDrag } from "@/lib/cross-project-dnd"; + +/** + * Cross-project drag bridge (board → project sidebar). + * + * The board uses dnd-kit (pointer sensor), while the leftmost project sidebar + * uses native drop targets marked with `data-project-drop-id`. dnd-kit's `over` + * is null outside its DndContext, so we hit-test the raw pointer position + * against those sidebar rows via the DOM. + */ +const PROJECT_DROP_ATTR = "data-project-drop-id"; +const SIDEBAR_DROP_CLASS = "cairn-cross-project-drop-active"; + +/** Returns the project id of the sidebar row under (x, y), or null. */ +function projectDropTargetAt(x: number, y: number): string | null { + if (typeof document === "undefined") return null; + let el = document.elementFromPoint(x, y) as HTMLElement | null; + while (el) { + const id = el.getAttribute?.(PROJECT_DROP_ATTR); + if (id) return id; + el = el.parentElement; + } + return null; +} + +/** Highlight the sidebar row under the pointer during a cross-project drag. */ +function updateSidebarDropHighlight(x: number, y: number, sourceProjectId: string): void { + if (typeof document === "undefined") return; + const target = projectDropTargetAt(x, y); + document.querySelectorAll(`.${SIDEBAR_DROP_CLASS}`).forEach((n) => n.classList.remove(SIDEBAR_DROP_CLASS)); + if (!target || target === sourceProjectId) return; + const row = document.querySelector(`[${PROJECT_DROP_ATTR}="${target}"]`); + row?.classList.add(SIDEBAR_DROP_CLASS); +} + +function clearSidebarDropHighlight(): void { + if (typeof document === "undefined") return; + document.querySelectorAll(`.${SIDEBAR_DROP_CLASS}`).forEach((n) => n.classList.remove(SIDEBAR_DROP_CLASS)); +} /** * Collision detection for the board. @@ -160,6 +199,7 @@ export function KanbanBoard() { restoreCard, archiveAllDoneCards, getArchivedProjectCards, + moveCardToProject, } = useCairnStore(useShallow((s) => ({ activeProjectId: s.activeProjectId, // Subscribe to cards directly so bulk archive/restore triggers a re-render. @@ -180,6 +220,7 @@ export function KanbanBoard() { reorderColumns: s.reorderColumns, restoreCard: s.restoreCard, archiveAllDoneCards: s.archiveAllDoneCards, + moveCardToProject: s.moveCardToProject, }))); const [activeCard, setActiveCard] = useState(null); @@ -228,7 +269,17 @@ export function KanbanBoard() { const col = event.active.data.current?.column as BoardColumn | undefined; const card = event.active.data.current?.card as TaskCard | undefined; if (col) { setActiveColumn(col); } - else if (card) { setActiveCard(card); } + else if (card) { + setActiveCard(card); + // Expose the drag to the project sidebar so it can be dropped onto another + // project (cross-project move preserving column state). + setActiveCrossProjectDrag({ + kind: "card", + cardId: card.id, + sourceProjectId: card.projectId, + label: card.title, + }); + } } const hoverZoneRef = useRef<"archive" | "delete" | null>(null); @@ -251,6 +302,8 @@ export function KanbanBoard() { if (!activeCard) { return; } const p = livePointer.current; if (!p) return; + // Highlight a sidebar project row if the pointer is over one. + updateSidebarDropHighlight(p.x, p.y, activeCard.projectId); const barRect = titleBarRef.current?.getBoundingClientRect() ?? null; const archive = archiveBtnRef.current?.getBoundingClientRect() ?? null; const del = deleteBtnRef.current?.getBoundingClientRect() ?? null; @@ -283,6 +336,8 @@ export function KanbanBoard() { hoverZoneRef.current = null; applyZoneHighlight(null); livePointer.current = null; + clearSidebarDropHighlight(); + setActiveCrossProjectDrag(null); } function handleDragEnd(event: DragEndEvent) { @@ -308,12 +363,16 @@ export function KanbanBoard() { hoverZoneRef.current = null; applyZoneHighlight(null); livePointer.current = null; + clearSidebarDropHighlight(); + setActiveCrossProjectDrag(null); archiveCard(draggedCard.id); return; } if (zone === "delete") { setDeleteFlashing(true); applyZoneHighlight("delete"); + clearSidebarDropHighlight(); + setActiveCrossProjectDrag(null); setTimeout(() => { setDeleteFlashing(false); setActiveCard(null); @@ -333,6 +392,24 @@ export function KanbanBoard() { setOverId(null); hoverZoneRef.current = null; applyZoneHighlight(null); + + // ── Cross-project drop ──────────────────────────────────────────────── + // dnd-kit's `over` is null when the pointer is released outside the board's + // DndContext (e.g. over the leftmost project sidebar). Hit-test the last + // pointer position for a project row and, if it belongs to a DIFFERENT + // project, move the card there (preserving its column state). + if (draggedCard) { + const targetProjectId = projectDropTargetAt(dropX, dropY); + if (targetProjectId && targetProjectId !== draggedCard.projectId) { + livePointer.current = null; + clearSidebarDropHighlight(); + setActiveCrossProjectDrag(null); + moveCardToProject(draggedCard.id, targetProjectId); + return; + } + } + clearSidebarDropHighlight(); + setActiveCrossProjectDrag(null); livePointer.current = null; if (!over || active.id === over.id) return; @@ -374,18 +451,24 @@ export function KanbanBoard() { // activatorEvent + delta for screen coordinates. useEffect(() => { if (!activeCard) { livePointer.current = null; return; } + const sourceProjectId = activeCard.projectId; function onPointerMove(e: PointerEvent) { livePointer.current = { x: e.clientX, y: e.clientY }; + updateSidebarDropHighlight(e.clientX, e.clientY, sourceProjectId); } function onTouchMove(e: TouchEvent) { const t = e.touches[0]; - if (t) livePointer.current = { x: t.clientX, y: t.clientY }; + if (t) { + livePointer.current = { x: t.clientX, y: t.clientY }; + updateSidebarDropHighlight(t.clientX, t.clientY, sourceProjectId); + } } window.addEventListener("pointermove", onPointerMove); window.addEventListener("touchmove", onTouchMove); return () => { window.removeEventListener("pointermove", onPointerMove); window.removeEventListener("touchmove", onTouchMove); + clearSidebarDropHighlight(); }; }, [activeCard]); const [highlightedColumnId, setHighlightedColumnId] = useState(null); diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index fdd38924..58c02a49 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -20,6 +20,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogClose } from "@ import { WorkspaceSwitcher } from "./sidebar/WorkspaceSwitcher"; import { ProjectCreateForm } from "./sidebar/ProjectCreateForm"; import { buildShortcutMap, modKey, countOpenCardsByProject, dueDateSeverity, dueDateDiffDays } from "./sidebar-utils"; +import { getActiveCrossProjectDrag, setActiveCrossProjectDrag } from "@/lib/cross-project-dnd"; import type { Project } from "@/types"; // ── View nav config ─────────────────────────────────────────────────────────── @@ -54,8 +55,8 @@ export function Sidebar() { createProject, updateProject, deleteProject, cards, chatOpen, searchOpen, hiddenViews, - } = useCairnStore(useShallow((s) => ({ - sidebarCollapsed: s.sidebarCollapsed, + moveFolderToProject, moveCardToProject, moveNoteToProject, + } = useCairnStore(useShallow((s) => ({ sidebarCollapsed: s.sidebarCollapsed, toggleSidebar: s.toggleSidebar, activeWorkspaceId: s.activeWorkspaceId, activeProjectId: s.activeProjectId, @@ -74,6 +75,9 @@ export function Sidebar() { chatOpen: s.chatOpen, searchOpen: s.searchOpen, hiddenViews: s.hiddenViews, + moveFolderToProject: s.moveFolderToProject, + moveCardToProject: s.moveCardToProject, + moveNoteToProject: s.moveNoteToProject, }))); // All navigable views in order (overview + notes always first) @@ -99,6 +103,23 @@ export function Sidebar() { } }, [sidebarCollapsed, toggleSidebar]); + // Cross-project drop: a notes folder or a task card dragged onto a project row. + // Returns true if a move was dispatched. Reads the active drag payload from the + // shared cross-project-dnd module (works for both native and dnd-kit sources). + const handleCrossProjectDrop = useCallback((targetProjectId: string): boolean => { + const drag = getActiveCrossProjectDrag(); + if (!drag || drag.sourceProjectId === targetProjectId) return false; + if (drag.kind === "folder") { + moveFolderToProject(drag.sourceProjectId, drag.folderPath, targetProjectId); + } else if (drag.kind === "note") { + moveNoteToProject(drag.noteId, targetProjectId); + } else { + moveCardToProject(drag.cardId, targetProjectId); + } + setActiveCrossProjectDrag(null); + return true; + }, [moveFolderToProject, moveCardToProject, moveNoteToProject]); + const [expandedProjects, setExpandedProjects] = useState>(new Set([activeProjectId ?? ""])); const [creatingProject, setCreatingProject] = useState(false); const [newProjectName, setNewProjectName] = useState(""); @@ -258,6 +279,7 @@ export function Sidebar() { openCardCount={openCardCountByProject.get(project.id) ?? 0} hiddenViews={hiddenViews} visibleNavItems={visibleNavItems} + onCrossProjectDrop={handleCrossProjectDrop} /> )); })()} @@ -317,12 +339,14 @@ interface ProjectItemProps { openCardCount: number; hiddenViews: Set; visibleNavItems: ViewNavItem[]; + onCrossProjectDrop: (targetProjectId: string) => boolean; } -function ProjectItem({ project, isActive, isExpanded, onToggleExpand, onSelectProject, activeView, onSelectView, onRename, onDelete, openCardCount, hiddenViews: _hiddenViews, visibleNavItems }: ProjectItemProps) { +function ProjectItem({ project, isActive, isExpanded, onToggleExpand, onSelectProject, activeView, onSelectView, onRename, onDelete, openCardCount, hiddenViews: _hiddenViews, visibleNavItems, onCrossProjectDrop }: ProjectItemProps) { const [renaming, setRenaming] = useState(false); const [renameValue, setRenameValue] = useState(project.name); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [isDropTarget, setIsDropTarget] = useState(false); const renameInputRef = useRef(null); useEffect(() => { @@ -360,8 +384,29 @@ function ProjectItem({ project, isActive, isExpanded, onToggleExpand, onSelectPr
-
+
{ + const drag = getActiveCrossProjectDrag(); + if (!drag || drag.sourceProjectId === project.id) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + if (!isDropTarget) setIsDropTarget(true); + }} + onDragLeave={() => { if (isDropTarget) setIsDropTarget(false); }} + onDrop={(e) => { + const drag = getActiveCrossProjectDrag(); + if (!drag || drag.sourceProjectId === project.id) return; + e.preventDefault(); + e.stopPropagation(); + setIsDropTarget(false); + onCrossProjectDrop(project.id); + }} + > diff --git a/src/components/notes/notes-view.tsx b/src/components/notes/notes-view.tsx index b7fc9e4d..a5398fc7 100644 --- a/src/components/notes/notes-view.tsx +++ b/src/components/notes/notes-view.tsx @@ -22,6 +22,7 @@ import { buildFolderTree, type FolderNode } from "./notes-view/buildFolderTree"; import { NoteListItem } from "./notes-view/NoteListItem"; import { ArchivedNoteListItem } from "./notes-view/ArchivedNoteListItem"; import { FolderTreeNode } from "./notes-view/FolderTreeNode"; +import { setActiveCrossProjectDrag } from "@/lib/cross-project-dnd"; import { FolderPickerDialog } from "./notes-view/FolderPickerDialog"; import { instantiateTemplate, defaultTitleFromTemplate } from "../../../shared/notes/templates"; import { STARTER_TEMPLATES } from "../../../shared/notes/starter-templates"; @@ -37,7 +38,7 @@ export function NotesView() { activeProjectId, activeWorkspaceId, getProjectNotes, getArchivedProjectNotes, getProjectTemplates, createNote, updateNote, deleteNote, - archiveNote, restoreNote, moveNoteToProject, moveNoteToFolder, + archiveNote, restoreNote, moveNoteToProject, moveNoteToFolder, moveFolder, revealNote, getTagById, getWorkspaceProjects, @@ -60,6 +61,7 @@ export function NotesView() { restoreNote: s.restoreNote, moveNoteToProject: s.moveNoteToProject, moveNoteToFolder: s.moveNoteToFolder, + moveFolder: s.moveFolder, revealNote: s.revealNote, getTagById: s.getTagById, getWorkspaceProjects: s.getWorkspaceProjects, @@ -160,20 +162,67 @@ export function NotesView() { const [folderMoveNoteId, setFolderMoveNoteId] = useState(null); const [, setFolderMoveDest] = useState(""); - // Drag-and-drop: note → folder + // Drag-and-drop: note → folder, and folder → folder (reparent subtree) const dragNoteIdRef = useRef(null); + const dragFolderPathRef = useRef(null); const [dropTarget, setDropTarget] = useState(null); // folder path or "__root__" + const [isDragging, setIsDragging] = useState(false); const handleNoteDragStart = useCallback((noteId: string) => { dragNoteIdRef.current = noteId; - }, []); + dragFolderPathRef.current = null; + setIsDragging(true); + // Also expose the drag to the leftmost project sidebar so a single note can + // be dropped onto another project (moves just that note, keeping its folder). + if (activeProjectId) { + const note = allNotes.find((n) => n.id === noteId); + setActiveCrossProjectDrag({ + kind: "note", + noteId, + sourceProjectId: activeProjectId, + label: note?.title ?? "note", + }); + } + }, [activeProjectId, allNotes]); const handleNoteDragEnd = useCallback(() => { dragNoteIdRef.current = null; setDropTarget(null); + setIsDragging(false); + setActiveCrossProjectDrag(null); + }, []); + + const handleFolderDragStart = useCallback((folderPath: string) => { + dragFolderPathRef.current = folderPath; + dragNoteIdRef.current = null; + setIsDragging(true); + // Also expose the drag to the leftmost project sidebar so the folder can be + // dropped onto another project (moves the whole subtree cross-project). + if (activeProjectId) { + setActiveCrossProjectDrag({ + kind: "folder", + sourceProjectId: activeProjectId, + folderPath, + label: folderPath.split("/").pop() ?? folderPath, + }); + } + }, [activeProjectId]); + + const handleFolderDragEndSource = useCallback(() => { + dragFolderPathRef.current = null; + setDropTarget(null); + setIsDragging(false); + setActiveCrossProjectDrag(null); }, []); const handleFolderDragOver = useCallback((folderPath: string) => { + // Don't highlight a folder as a drop target when dragging it onto itself + // or into one of its own descendants (an illegal reparent). + const dragged = dragFolderPathRef.current; + if (dragged && (folderPath === dragged || folderPath.startsWith(`${dragged}/`))) { + setDropTarget(null); + return; + } setDropTarget(folderPath); }, []); @@ -182,11 +231,21 @@ export function NotesView() { }, []); const handleFolderDrop = useCallback((folderPath: string) => { - const noteId = dragNoteIdRef.current; - if (noteId) moveNoteToFolder(noteId, folderPath); + const folderPathDragged = dragFolderPathRef.current; + if (folderPathDragged && activeProjectId) { + // folderPath is the DESTINATION folder ("" = root). Reparent the dragged + // subtree so it lives inside the destination folder. + moveFolder(activeProjectId, folderPathDragged, folderPath); + } else { + const noteId = dragNoteIdRef.current; + if (noteId) moveNoteToFolder(noteId, folderPath); + } dragNoteIdRef.current = null; + dragFolderPathRef.current = null; setDropTarget(null); - }, [moveNoteToFolder]); + setIsDragging(false); + }, [moveNoteToFolder, moveFolder, activeProjectId]); + // Subscribe to allNotes directly so this component re-renders when note // content changes (e.g. while typing). The selector functions are stable @@ -543,13 +602,15 @@ export function NotesView() { dropTarget={dropTarget} onNoteDragStart={handleNoteDragStart} onNoteDragEnd={handleNoteDragEnd} + onFolderDragStart={handleFolderDragStart} + onFolderDragEndSource={handleFolderDragEndSource} onFolderDragOver={handleFolderDragOver} onFolderDragLeave={handleFolderDragLeave} onFolderDrop={handleFolderDrop} /> ))} {/* Root drop zone — visible only while dragging, when folders exist */} - {folderTree.length > 0 && dropTarget !== null && ( + {folderTree.length > 0 && (dropTarget !== null || isDragging) && (
void; onNoteDragEnd: () => void; + onFolderDragStart: (folderPath: string) => void; + onFolderDragEndSource: () => void; onFolderDragOver: (folderPath: string) => void; onFolderDragLeave: () => void; onFolderDrop: (folderPath: string) => void; @@ -34,7 +36,9 @@ export const FolderTreeNode = memo(function FolderTreeNode({ node, activeNoteId, collapsed, depth = 0, onToggle, onNoteClick, onNotePin, onNoteDelete, onNoteArchive, onNoteMove, onNoteMoveToFolder, onNoteReveal, onCreateInFolder, - dropTarget, onNoteDragStart, onNoteDragEnd, onFolderDragOver, onFolderDragLeave, onFolderDrop, + dropTarget, onNoteDragStart, onNoteDragEnd, + onFolderDragStart, onFolderDragEndSource, + onFolderDragOver, onFolderDragLeave, onFolderDrop, }: FolderTreeNodeProps) { const isCollapsed = collapsed[node.path]; const indent = depth * 10; @@ -51,6 +55,9 @@ export const FolderTreeNode = memo(function FolderTreeNode({ )} style={{ paddingLeft: `${8 + indent}px` }} onClick={() => onToggle(node.path)} + draggable + onDragStart={(e) => { e.stopPropagation(); onFolderDragStart(node.path); }} + onDragEnd={(e) => { e.stopPropagation(); onFolderDragEndSource(); }} onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); onFolderDragOver(node.path); }} onDragLeave={(e) => { e.stopPropagation(); onFolderDragLeave(); }} onDrop={(e) => { e.preventDefault(); e.stopPropagation(); onFolderDrop(node.path); }} @@ -94,6 +101,8 @@ export const FolderTreeNode = memo(function FolderTreeNode({ dropTarget={dropTarget} onNoteDragStart={onNoteDragStart} onNoteDragEnd={onNoteDragEnd} + onFolderDragStart={onFolderDragStart} + onFolderDragEndSource={onFolderDragEndSource} onFolderDragOver={onFolderDragOver} onFolderDragLeave={onFolderDragLeave} onFolderDrop={onFolderDrop} diff --git a/src/lib/cross-project-dnd.ts b/src/lib/cross-project-dnd.ts new file mode 100644 index 00000000..7ed60f79 --- /dev/null +++ b/src/lib/cross-project-dnd.ts @@ -0,0 +1,63 @@ +/** + * Cross-project drag-and-drop payload contract. + * + * Used to drag items (a notes folder, or a task card) from one place in the app + * onto a project row in the leftmost sidebar to move them to another project. + * + * We piggyback on the native HTML5 DataTransfer with a custom MIME type so the + * payload survives across independent components (notes sidebar, kanban board, + * project sidebar) without a shared React ref or global store field. + * + * dnd-kit (used by the board) doesn't expose the native dataTransfer on its + * synthetic events, so the board also mirrors the active payload into a module + * singleton (`activeCrossProjectDrag`) that the sidebar can read on drop. + */ + +export const CROSS_PROJECT_DND_MIME = "application/x-cairn-cross-project"; + +export type CrossProjectDragPayload = + | { kind: "folder"; sourceProjectId: string; folderPath: string; label: string } + | { kind: "note"; noteId: string; sourceProjectId: string; label: string } + | { kind: "card"; cardId: string; sourceProjectId: string; label: string }; + +/** + * Module-level mirror of the payload currently being dragged. Set on drag start, + * cleared on drag end. The sidebar reads this as a fallback when the native + * dataTransfer isn't available (e.g. a dnd-kit-originated drag). + */ +let active: CrossProjectDragPayload | null = null; + +export function setActiveCrossProjectDrag(payload: CrossProjectDragPayload | null): void { + active = payload; +} + +export function getActiveCrossProjectDrag(): CrossProjectDragPayload | null { + return active; +} + +/** Attach the payload to a native drag event's dataTransfer (best-effort). */ +export function writeCrossProjectDrag( + dt: DataTransfer | null | undefined, + payload: CrossProjectDragPayload, +): void { + setActiveCrossProjectDrag(payload); + try { + dt?.setData(CROSS_PROJECT_DND_MIME, JSON.stringify(payload)); + if (dt) dt.effectAllowed = "move"; + } catch { + /* dataTransfer unavailable — the module mirror still carries the payload */ + } +} + +/** Read the payload from a drop event, falling back to the module mirror. */ +export function readCrossProjectDrag( + dt: DataTransfer | null | undefined, +): CrossProjectDragPayload | null { + try { + const raw = dt?.getData(CROSS_PROJECT_DND_MIME); + if (raw) return JSON.parse(raw) as CrossProjectDragPayload; + } catch { + /* fall through to the module mirror */ + } + return getActiveCrossProjectDrag(); +} diff --git a/src/store/slices/board.test.ts b/src/store/slices/board.test.ts index 6dbebdc8..777ab548 100644 --- a/src/store/slices/board.test.ts +++ b/src/store/slices/board.test.ts @@ -260,3 +260,64 @@ describe("getReadyCards", () => { expect(get().getReadyCards("proj-1").map((c: TaskCard) => c.id)).toEqual(["a"]); }); }); + +describe("moveCardToProject", () => { + const projects = [ + { id: "proj-1", workspaceId: "ws-1", name: "P1" }, + { id: "proj-2", workspaceId: "ws-1", name: "P2" }, + ]; + + it("lands the card in the target project's column of the SAME type", () => { + const { get } = setup({ + projects, + columns: [ + col("s-todo", "proj-1", "todo", 1), + col("s-prog", "proj-1", "in_progress", 2), + col("t-backlog", "proj-2", "backlog", 0), + col("t-todo", "proj-2", "todo", 1), + col("t-prog", "proj-2", "in_progress", 2), + ], + cards: [card("x", "s-prog", 0, { projectId: "proj-1" })], + }); + + get().moveCardToProject("x", "proj-2"); + + const moved = get().cards.find((c: TaskCard) => c.id === "x"); + expect(moved.projectId).toBe("proj-2"); + expect(moved.columnId).toBe("t-prog"); // in_progress → in_progress + }); + + it("falls back to backlog when the target has no same-type column", () => { + const { get } = setup({ + projects, + columns: [ + col("s-review", "proj-1", "review", 3), + col("t-backlog", "proj-2", "backlog", 0), + col("t-todo", "proj-2", "todo", 1), + ], + cards: [card("x", "s-review", 0, { projectId: "proj-1" })], + }); + + get().moveCardToProject("x", "proj-2"); + + const moved = get().cards.find((c: TaskCard) => c.id === "x"); + expect(moved.columnId).toBe("t-backlog"); // no review column → backlog + }); + + it("falls back to the first column when the target has neither same-type nor backlog", () => { + const { get } = setup({ + projects, + columns: [ + col("s-review", "proj-1", "review", 3), + col("t-first", "proj-2", "todo", 0), + col("t-done", "proj-2", "done", 1), + ], + cards: [card("x", "s-review", 0, { projectId: "proj-1" })], + }); + + get().moveCardToProject("x", "proj-2"); + + const moved = get().cards.find((c: TaskCard) => c.id === "x"); + expect(moved.columnId).toBe("t-first"); // lowest order + }); +}); diff --git a/src/store/slices/board.ts b/src/store/slices/board.ts index 663481f6..cebb3b30 100644 --- a/src/store/slices/board.ts +++ b/src/store/slices/board.ts @@ -340,8 +340,14 @@ export const createBoardSlice: StateCreator = ( const targetColumns = state.columns .filter((c) => c.projectId === targetProjectId) .sort((a, b) => a.order - b.order); + // Preserve the card's state: land it in the target project's column of the + // SAME type (e.g. in_progress → in_progress). Fall back to the target's + // backlog column, then to its first column, if no same-type column exists. + const sourceColumn = state.columns.find((c) => c.id === card.columnId); const targetColumn = - targetColumns.find((c) => c.type === "backlog") ?? targetColumns[0]; + (sourceColumn && targetColumns.find((c) => c.type === sourceColumn.type)) ?? + targetColumns.find((c) => c.type === "backlog") ?? + targetColumns[0]; if (!targetColumn) return; const newOrder = state.cards.filter( (c) => c.columnId === targetColumn.id && !c.archivedAt diff --git a/src/store/slices/notes.test.ts b/src/store/slices/notes.test.ts new file mode 100644 index 00000000..617febe5 --- /dev/null +++ b/src/store/slices/notes.test.ts @@ -0,0 +1,89 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * Unit tests for moveFolder — reparenting a folder subtree within a project. + * + * Runs in the node env (isElectron() === false), so IPC is a no-op. + */ + +import { describe, it, expect } from "vitest"; +import { createNotesSlice } from "./notes"; +import type { Note } from "@/types"; + +const note = (id: string, folder: string, projectId = "proj-1"): Note => + ({ + id, + projectId, + workspaceId: "ws-1", + title: id, + content: "", + contentText: "", + tagIds: [], + linkedNoteIds: [], + linkedCardIds: [], + isPinned: false, + type: "note", + folder, + createdAt: "", + updatedAt: "", + version: 0, + } as unknown as Note); + +function setup(notes: Note[]) { + let state: any = { persist: () => {}, projects: [{ id: "proj-1", workspaceId: "ws-1", name: "P" }] }; + const mockSet = (updater: any) => { + const next = typeof updater === "function" ? updater(state) : updater; + state = { ...state, ...next }; + }; + const mockGet = () => state; + const slice = createNotesSlice(mockSet, mockGet, {} as any); + // Apply the slice first (it seeds notes: []), then inject the test notes so + // they aren't clobbered by the slice's own initial state. + state = { ...state, ...slice, notes }; + return { get: () => state, slice }; +} + +const folderOf = (get: () => any, id: string) => get().notes.find((n: Note) => n.id === id).folder; + +describe("moveFolder", () => { + it("reparents a folder and re-prefixes all descendant notes", () => { + const { get } = setup([ + note("a", "Design"), + note("b", "Design/Typography"), + note("c", "Design/Typography/Deep"), + note("d", "Other"), + ]); + // Move "Design" inside "Other" → "Other/Design" + get().moveFolder("proj-1", "Design", "Other"); + expect(folderOf(get, "a")).toBe("Other/Design"); + expect(folderOf(get, "b")).toBe("Other/Design/Typography"); + expect(folderOf(get, "c")).toBe("Other/Design/Typography/Deep"); + expect(folderOf(get, "d")).toBe("Other"); // untouched + }); + + it("moves a folder to root", () => { + const { get } = setup([note("a", "Design/Typography"), note("b", "Design/Typography/X")]); + get().moveFolder("proj-1", "Design/Typography", ""); + expect(folderOf(get, "a")).toBe("Typography"); + expect(folderOf(get, "b")).toBe("Typography/X"); + }); + + it("is a no-op when dropping a folder into itself", () => { + const { get } = setup([note("a", "Design")]); + get().moveFolder("proj-1", "Design", "Design"); + expect(folderOf(get, "a")).toBe("Design"); + }); + + it("is a no-op when dropping a folder into its own descendant", () => { + const { get } = setup([note("a", "Design"), note("b", "Design/Sub")]); + get().moveFolder("proj-1", "Design", "Design/Sub"); + expect(folderOf(get, "a")).toBe("Design"); + expect(folderOf(get, "b")).toBe("Design/Sub"); + }); + + it("does not touch notes in other projects", () => { + const { get } = setup([note("a", "Design", "proj-1"), note("x", "Design", "proj-2")]); + get().moveFolder("proj-1", "Design", "Other"); + expect(folderOf(get, "a")).toBe("Other/Design"); + expect(folderOf(get, "x")).toBe("Design"); // different project — untouched + }); +}); diff --git a/src/store/slices/notes.ts b/src/store/slices/notes.ts index 741f54bb..35ca9bb8 100644 --- a/src/store/slices/notes.ts +++ b/src/store/slices/notes.ts @@ -7,6 +7,7 @@ import type { CairnStore } from "../index"; import type { Note, ID, NoteType } from "@/types"; import { id, now } from "@/lib/utils"; import { ipc, isElectron, markOwnNoteWrite } from "../ipc"; +import { normalizeFolderPath } from "../../../shared/notes/folder-tree"; import { historyManager } from "@/lib/history"; import { makeCreateNoteCmd, @@ -46,6 +47,21 @@ export interface NotesSlice { moveNoteToProject: (noteId: ID, targetProjectId: ID) => void; /** Move a note to a subfolder within its current project. folder="" moves to root. */ moveNoteToFolder: (noteId: ID, folder: string) => void; + /** + * Move (reparent) an entire folder and everything under it within a project. + * `sourcePath` is the folder being dragged (e.g. "Design/Typography"); + * `destParentPath` is the folder it should live inside ("" = project root). + * Every note under `sourcePath` has its `folder` re-prefixed accordingly. + * No-op if the move would nest a folder inside itself. + */ + moveFolder: (projectId: ID, sourcePath: string, destParentPath: string) => void; + /** + * Move an entire folder subtree to another PROJECT, preserving its internal + * folder structure. Every note under `sourcePath` in `sourceProjectId` is + * moved to `targetProjectId` keeping its `folder` path. No-op if the target + * project doesn't exist or nothing matches. + */ + moveFolderToProject: (sourceProjectId: ID, sourcePath: string, targetProjectId: ID) => void; linkNoteToCard: (noteId: ID, cardId: ID) => void; /** Reveal the note's .md file in the OS file explorer. No-op outside Electron. */ revealNote: (noteId: ID, projectId: ID) => void; @@ -188,6 +204,78 @@ export const createNotesSlice: StateCreator = ( ipc((e) => e.note.moveToFolder(noteId, folder)); }, + moveFolder(projectId, sourcePath, destParentPath) { + const source = normalizeFolderPath(sourcePath); + const destParent = normalizeFolderPath(destParentPath); + if (!source) return; // can't move the root itself + + const folderName = source.split("/").pop()!; + const newBase = destParent ? `${destParent}/${folderName}` : folderName; + + // No-op: dropping onto the same parent, or onto itself / a descendant (which + // would nest the folder inside itself and orphan the notes). + if (newBase === source || newBase.startsWith(`${source}/`)) return; + + const srcLower = source.toLowerCase(); + const affected = get().notes.filter((n) => { + if (n.projectId !== projectId) return false; + const f = normalizeFolderPath(n.folder).toLowerCase(); + return f === srcLower || f.startsWith(`${srcLower}/`); + }); + if (affected.length === 0) return; + + const nowTs = now(); + const remap = new Map(); + for (const n of affected) { + const f = normalizeFolderPath(n.folder); + // Replace the source prefix with the new base, preserving the suffix path. + const suffix = f.slice(source.length); // "" or "/sub/deeper" + remap.set(n.id, `${newBase}${suffix}`); + } + + set((s) => ({ + notes: s.notes.map((n) => + remap.has(n.id) ? { ...n, folder: remap.get(n.id)!, updatedAt: nowTs } : n + ), + })); + get().persist(); + for (const [noteId, folder] of remap) { + markOwnNoteWrite(noteId); + ipc((e) => e.note.moveToFolder(noteId, folder)); + } + }, + + moveFolderToProject(sourceProjectId, sourcePath, targetProjectId) { + if (sourceProjectId === targetProjectId) return; + const source = normalizeFolderPath(sourcePath); + if (!source) return; + const targetProject = get().projects.find((p) => p.id === targetProjectId); + if (!targetProject) return; + + const srcLower = source.toLowerCase(); + const affected = get().notes.filter((n) => { + if (n.projectId !== sourceProjectId) return false; + const f = normalizeFolderPath(n.folder).toLowerCase(); + return f === srcLower || f.startsWith(`${srcLower}/`); + }); + if (affected.length === 0) return; + + const nowTs = now(); + const ids = affected.map((n) => n.id); + set((s) => ({ + notes: s.notes.map((n) => + ids.includes(n.id) + ? { ...n, projectId: targetProjectId, workspaceId: targetProject.workspaceId, updatedAt: nowTs } + : n + ), + })); + get().persist(); + for (const noteId of ids) { + markOwnNoteWrite(noteId); + ipc((e) => e.note.moveToProject(noteId, targetProjectId, targetProject.workspaceId)); + } + }, + linkNoteToCard(noteId, cardId) { const prevNote = get().notes.find((n) => n.id === noteId); const prevCard = get().cards.find((c) => c.id === cardId); From fe84635fc7960f8f8093af2b393e940cb41e0c7b Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Wed, 22 Jul 2026 12:55:04 -0400 Subject: [PATCH 3/4] chore(chat): lint cleanup on subagent benchmark tests Remove unnecessary eslint-disable no-console directives and an unused afterEach import from the subagent test harnesses. --- electron/lib/chat-agent-benchmark.test.ts | 14 +++++++------- electron/lib/chat-quality-scorer.test.ts | 10 +++++----- electron/lib/chat-subagent-stream.test.ts | 8 ++++---- electron/lib/chat-write-recovery-benchmark.test.ts | 6 +++--- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/electron/lib/chat-agent-benchmark.test.ts b/electron/lib/chat-agent-benchmark.test.ts index 0d91b856..6d2e2497 100644 --- a/electron/lib/chat-agent-benchmark.test.ts +++ b/electron/lib/chat-agent-benchmark.test.ts @@ -28,7 +28,7 @@ * npx vitest run electron/lib/chat-agent-benchmark.test.ts */ -import { describe, it, beforeEach, afterEach, expect } from "vitest"; +import { describe, it, beforeEach, expect } from "vitest"; import type Database from "better-sqlite3"; import BetterSqlite3 from "better-sqlite3"; import fs from "fs"; @@ -270,11 +270,11 @@ describe("chat agent benchmark — tool-array context cost (offline)", () => { `write-subagent,${WRITE_TOOL_NAMES.size},${write}`, `dispatcher,2,${dispatch}`, ]; - // eslint-disable-next-line no-console + console.log("\n=== Tool-array context cost (tokens sent EVERY turn) ===\n" + rows.join("\n")); const worstSubagentTurn = Math.max(research, write, dispatch); - // eslint-disable-next-line no-console + console.log( `\nSingle-agent pays ${single} tok/turn for tools.` + `\nWorst subagent turn pays ${worstSubagentTurn} tok/turn (${Math.round((1 - worstSubagentTurn / single) * 100)}% less).`, @@ -295,7 +295,7 @@ describe.skipIf(!!process.env.CAIRN_SKIP_LIVE_TESTS)("chat agent benchmark — l it("runs the task set through both architectures and logs CSV", async () => { if (!up) { - // eslint-disable-next-line no-console + console.log(`[skip] No LLM endpoint reachable at ${BASE_URL}. Set TEST_LLM_BASE_URL/MODEL/API_KEY to run the live benchmark.`); return; } @@ -311,7 +311,7 @@ describe.skipIf(!!process.env.CAIRN_SKIP_LIVE_TESTS)("chat agent benchmark — l try { const rec = arch === "single" ? await runSingle(db, wp, task) : await runSub(db, wp, task); rows.push(rec); - // eslint-disable-next-line no-console + console.log( `${arch.padEnd(9)} ${task.id} ptok=${String(rec.promptTokens).padStart(6)} ` + `ctok=${String(rec.completionTokens).padStart(5)} ${String(rec.latencyMs).padStart(6)}ms ` + @@ -326,14 +326,14 @@ describe.skipIf(!!process.env.CAIRN_SKIP_LIVE_TESTS)("chat agent benchmark — l const csv = toCsv(rows); const outPath = path.join(wp, "chat-agent-benchmark.csv"); fs.writeFileSync(outPath, csv); - // eslint-disable-next-line no-console + console.log("\n=== CSV ===\n" + csv + "\n\nWritten to: " + outPath); // Aggregate: per-arch totals. for (const arch of ["single", "subagent"] as const) { const a = rows.filter((r) => r.arch === arch); const sum = (f: (r: RunRecord) => number) => a.reduce((s, r) => s + f(r), 0); - // eslint-disable-next-line no-console + console.log( `TOTAL ${arch.padEnd(9)} ptok=${sum((r) => r.promptTokens)} ctok=${sum((r) => r.completionTokens)} ` + `latency=${sum((r) => r.latencyMs)}ms calls=${sum((r) => r.toolCalls)} errors=${sum((r) => r.toolErrors)}`, diff --git a/electron/lib/chat-quality-scorer.test.ts b/electron/lib/chat-quality-scorer.test.ts index fd2ac67b..2be2603c 100644 --- a/electron/lib/chat-quality-scorer.test.ts +++ b/electron/lib/chat-quality-scorer.test.ts @@ -242,11 +242,11 @@ describe.skipIf(!!process.env.CAIRN_SKIP_LIVE_TESTS)("chat output-quality scorer it("scores single vs subagent deliverables against the rubric", async () => { if (!up) { - // eslint-disable-next-line no-console + console.log(`[skip] agent endpoint ${BASE_URL} or judge ${JUDGE_BASE_URL} unreachable.`); return; } - // eslint-disable-next-line no-console + console.log(`Agent: ${MODEL} @ ${BASE_URL} Judge: ${JUDGE_MODEL} @ ${JUDGE_BASE_URL}`); const wp = fs.mkdtempSync(path.join(os.tmpdir(), "cairn-quality-")); @@ -261,7 +261,7 @@ describe.skipIf(!!process.env.CAIRN_SKIP_LIVE_TESTS)("chat output-quality scorer const total = CRITERIA.reduce((sum, c) => sum + s[c], 0); const noteChars = artifact.notes.reduce((sum, n) => sum + n.content.length, 0); rows.push({ arch, task: task.id, s, total, noteChars }); - // eslint-disable-next-line no-console + console.log( `${arch.padEnd(9)} ${task.id} total=${total}/25 ` + `[tc=${s.task_completion} th=${s.theme_coverage} f=${s.faithfulness} st=${s.structure} act=${s.actionability}] ` + @@ -275,13 +275,13 @@ describe.skipIf(!!process.env.CAIRN_SKIP_LIVE_TESTS)("chat output-quality scorer const csv = [header, ...rows.map((r) => [r.arch, r.task, r.s.task_completion, r.s.theme_coverage, r.s.faithfulness, r.s.structure, r.s.actionability, r.total, r.noteChars].join(","))].join("\n"); fs.writeFileSync(path.join(wp, "quality-scores.csv"), csv); - // eslint-disable-next-line no-console + console.log("\n=== CSV ===\n" + csv); for (const arch of ["single", "subagent"] as const) { const a = rows.filter((r) => r.arch === arch); const avg = (f: (r: ScoreRow) => number) => (a.reduce((s, r) => s + f(r), 0) / a.length).toFixed(2); - // eslint-disable-next-line no-console + console.log( `AVG ${arch.padEnd(9)} total=${avg((r) => r.total)}/25 ` + `tc=${avg((r) => r.s.task_completion)} th=${avg((r) => r.s.theme_coverage)} ` + diff --git a/electron/lib/chat-subagent-stream.test.ts b/electron/lib/chat-subagent-stream.test.ts index 7a1d7a61..042b3a27 100644 --- a/electron/lib/chat-subagent-stream.test.ts +++ b/electron/lib/chat-subagent-stream.test.ts @@ -38,7 +38,7 @@ describe.skipIf(!!process.env.CAIRN_SKIP_LIVE_TESTS)("subagent chat streaming (l it("emits subagent start/done + token/tool-call events", async () => { if (!up) { - // eslint-disable-next-line no-console + console.log(`[skip] no endpoint at ${BASE_URL}`); return; } @@ -74,11 +74,11 @@ describe.skipIf(!!process.env.CAIRN_SKIP_LIVE_TESTS)("subagent chat streaming (l db.close(); const m = result.metrics; - // eslint-disable-next-line no-console + console.log(`starts=${starts.length} dones=${dones.length} tokens=${tokenCount} finalChars=${result.content.length}`); - // eslint-disable-next-line no-console + console.log(`subagent roles: ${starts.join(", ")}`); - // eslint-disable-next-line no-console + console.log(`TOTAL promptTokens=${m.promptTokens} DISPATCHER context=${m.dispatcherPromptTokens} perSubagentUsage=${[...subUsage.values()].join(",")}`); // At least one subagent must have started + finished, and produced a final reply. diff --git a/electron/lib/chat-write-recovery-benchmark.test.ts b/electron/lib/chat-write-recovery-benchmark.test.ts index 47ce4495..c3012fe5 100644 --- a/electron/lib/chat-write-recovery-benchmark.test.ts +++ b/electron/lib/chat-write-recovery-benchmark.test.ts @@ -191,7 +191,7 @@ describe.skipIf(!!process.env.CAIRN_SKIP_LIVE_TESTS)("write-failure recovery (fa it("compares strict-write vs hybrid-write vs single on a forced bad oldString", async () => { if (!up) { - // eslint-disable-next-line no-console + console.log(`[skip] No LLM endpoint at ${BASE_URL}. Set TEST_LLM_* to run.`); return; } @@ -216,7 +216,7 @@ describe.skipIf(!!process.env.CAIRN_SKIP_LIVE_TESTS)("write-failure recovery (fa } for (const r of rows) { - // eslint-disable-next-line no-console + console.log( `${r.config.padEnd(9)} recovered=${r.recovered ? "YES" : "NO "} ` + `faultFired=${r.faultFired ? "y" : "n"} ptok=${String(r.promptTokens).padStart(6)} ` + @@ -230,7 +230,7 @@ describe.skipIf(!!process.env.CAIRN_SKIP_LIVE_TESTS)("write-failure recovery (fa [r.config, r.recovered, r.faultFired, r.promptTokens, r.completionTokens, r.latencyMs, r.toolCalls, r.toolErrors, r.writeInvocations].join(","))].join("\n"); const outPath = path.join(wp, "write-recovery.csv"); fs.writeFileSync(outPath, csv); - // eslint-disable-next-line no-console + console.log("\n=== CSV ===\n" + csv + "\n\nWritten to: " + outPath); // faultFired / recovered / writeInvocations are the FINDINGS, logged above. From 59bf9b2e4ca8448b2e44d404f65c720f68b038b5 Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Wed, 22 Jul 2026 13:20:31 -0400 Subject: [PATCH 4/4] chore: address PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subagent chat: - Thread abortCtrl.signal through runDispatchLoop → runSubagent → forceFinalAnswer and the nested runToolLoop so user cancellation stops every model/tool fetch; wrap the dispatch call in try/catch/finally that always removes the abort controller and emits chat:done (cancel or error). - Catch network failures in the dispatch fetch and return the same graceful error-result shape as the single-agent loop instead of throwing. - useChatStream: include write sub-agent tool calls when deciding hasPersistedWrite so subagent-mode writes still refresh the board/notes. Cross-project drag / notes: - notes-io: replace .md-only dir check with dirContainsRealData so directory cleanup preserves any non-cruft file (attachments, .canvas, images) and only deletes recognised OS cruft (.DS_Store, Thumbs.db, …). - notes slice: extract shared notesInFolderSubtree helper used by moveFolder and moveFolderToProject; add moveFolderToProject unit tests. - board: drop redundant sidebar-highlight call in handleDragMove (listeners are the sole source). - cross-project-dnd: remove unused write/readCrossProjectDrag exports. Tests: - Extract shared electron/lib/bench-endpoint.ts (BASE_URL/MODEL/API_KEY/ endpointUp) consumed by the four benchmark tests; preserve the quality scorer's judge-endpoint override. --- electron/ipc/chat.ts | 83 +++++++++++-------- electron/lib/bench-endpoint.ts | 27 ++++++ electron/lib/chat-agent-benchmark.test.ts | 20 +---- electron/lib/chat-quality-scorer.test.ts | 12 +-- electron/lib/chat-subagent-loop.ts | 56 ++++++++----- electron/lib/chat-subagent-stream.test.ts | 13 +-- .../lib/chat-write-recovery-benchmark.test.ts | 15 +--- electron/shared/notes-io.ts | 27 ++++-- src/components/kanban/board.tsx | 5 +- src/hooks/useChatStream.ts | 11 ++- src/lib/cross-project-dnd.ts | 27 ------ src/store/slices/notes.test.ts | 63 +++++++++++++- src/store/slices/notes.ts | 28 ++++--- 13 files changed, 224 insertions(+), 163 deletions(-) create mode 100644 electron/lib/bench-endpoint.ts diff --git a/electron/ipc/chat.ts b/electron/ipc/chat.ts index 93f2e432..db146d88 100644 --- a/electron/ipc/chat.ts +++ b/electron/ipc/chat.ts @@ -216,45 +216,58 @@ export function registerChatHandler(db: Database.Database, workspacePath: string // chat:subagent* events, then streams the dispatcher's final reply as tokens. if (req.useSubagents && provider !== "localllm") { const { runDispatchLoop } = await import("../lib/chat-subagent-loop"); - const dispatchResult = await runDispatchLoop( - db, req, workspacePath, { baseUrl, model, apiKey, provider }, - getWin, - { - events: { - onSubagentStart: (e) => send("chat:subagent", { ...e, status: "start" }), - onSubagentDone: (e) => send("chat:subagent", { ...e, status: "done" }), - onSubagentToken: (e) => send("chat:subagent-token", e), - onSubagentThought: (e) => send("chat:subagent-thought", e), - onSubagentToolCall: (e) => send("chat:subagent-tool-call", e), - onSubagentToolCallDone: (e) => send("chat:subagent-tool-call-done", e), - onSubagentUsage: (e) => send("chat:subagent-usage", e), + try { + const dispatchResult = await runDispatchLoop( + db, req, workspacePath, { baseUrl, model, apiKey, provider }, + getWin, + { + signal: abortCtrl.signal, + events: { + onSubagentStart: (e) => send("chat:subagent", { ...e, status: "start" }), + onSubagentDone: (e) => send("chat:subagent", { ...e, status: "done" }), + onSubagentToken: (e) => send("chat:subagent-token", e), + onSubagentThought: (e) => send("chat:subagent-thought", e), + onSubagentToolCall: (e) => send("chat:subagent-tool-call", e), + onSubagentToolCallDone: (e) => send("chat:subagent-tool-call-done", e), + onSubagentUsage: (e) => send("chat:subagent-usage", e), + }, }, - }, - ); + ); - // The main/total ContextRing must reflect the DISPATCHER's context window — - // the system prompt + the subagent briefs fed back — NOT the summed cost of - // every subagent turn. Each subagent reports its own usage via - // chat:subagent-usage for its own ring. (metrics.promptTokens is the total - // cost figure; metrics.dispatcherPromptTokens is the context figure.) - const m = dispatchResult.metrics; - promptTokens = m.dispatcherPromptTokens; - completionTokens = m.dispatcherCompletionTokens; - reasoningTokens = m.dispatcherReasoningTokens; - send("chat:usage", { promptTokens, completionTokens, reasoningTokens, breakdown: lastBreakdown }); + // The main/total ContextRing must reflect the DISPATCHER's context window — + // the system prompt + the subagent briefs fed back — NOT the summed cost of + // every subagent turn. Each subagent reports its own usage via + // chat:subagent-usage for its own ring. (metrics.promptTokens is the total + // cost figure; metrics.dispatcherPromptTokens is the context figure.) + const m = dispatchResult.metrics; + promptTokens = m.dispatcherPromptTokens; + completionTokens = m.dispatcherCompletionTokens; + reasoningTokens = m.dispatcherReasoningTokens; + send("chat:usage", { promptTokens, completionTokens, reasoningTokens, breakdown: lastBreakdown }); - // Stream the dispatcher's final reply as content tokens so it renders like - // a normal assistant message. - if (dispatchResult.content) send("chat:token", { delta: dispatchResult.content }); + // Stream the dispatcher's final reply as content tokens so it renders like + // a normal assistant message. + if (!abortCtrl.signal.aborted && dispatchResult.content) send("chat:token", { delta: dispatchResult.content }); - abortControllers.delete(event.sender.id); - if (!abortCtrl.signal.aborted) broadcastEvent("db:changed", null); - send("chat:done", { - content: dispatchResult.content, - reasoning: dispatchResult.reasoning, - contextRefs: [], - usage: promptTokens > 0 ? { promptTokens, completionTokens, reasoningTokens, breakdown: lastBreakdown } : undefined, - }); + if (!abortCtrl.signal.aborted) broadcastEvent("db:changed", null); + send("chat:done", { + content: abortCtrl.signal.aborted ? "" : dispatchResult.content, + reasoning: dispatchResult.reasoning, + contextRefs: [], + usage: promptTokens > 0 ? { promptTokens, completionTokens, reasoningTokens, breakdown: lastBreakdown } : undefined, + }); + } catch (err) { + // Cancellation or an unexpected failure must never leave the input + // disabled — always emit chat:done so the renderer resolves the turn. + if (!abortCtrl.signal.aborted) { + console.error("[chat] subagent dispatch failed:", err); + send("chat:done", { content: `Subagent run failed: ${String(err)}`, contextRefs: [] }); + } else { + send("chat:done", { content: "", contextRefs: [] }); + } + } finally { + abortControllers.delete(event.sender.id); + } return; } diff --git a/electron/lib/bench-endpoint.ts b/electron/lib/bench-endpoint.ts new file mode 100644 index 00000000..f167a7b5 --- /dev/null +++ b/electron/lib/bench-endpoint.ts @@ -0,0 +1,27 @@ +/** + * Shared live-endpoint config + reachability probe for the chat/subagent + * benchmarks and smoke tests. Reads the repo's standard TEST_LLM_* env vars. + * Live suites gate on `endpointUp()` (and CAIRN_SKIP_LIVE_TESTS) so they no-op + * cleanly when no endpoint is configured. + */ + +import { normaliseBaseUrl } from "./llm"; + +export const BASE_URL = normaliseBaseUrl( + process.env.TEST_LLM_BASE_URL?.trim() || "http://localhost:1234/v1", +); +export const MODEL = process.env.TEST_LLM_MODEL?.trim() || "gpt-4o-mini"; +export const API_KEY = process.env.TEST_LLM_API_KEY?.trim() || ""; + +/** True if the given (or default) endpoint is reachable. */ +export async function endpointUp(url: string = BASE_URL, key: string = API_KEY): Promise { + try { + const res = await fetch(`${url}/v1/models`, { + headers: key ? { Authorization: `Bearer ${key}` } : {}, + signal: AbortSignal.timeout(2500), + }); + return res.ok || res.status === 401 || res.status === 404; + } catch { + return false; + } +} diff --git a/electron/lib/chat-agent-benchmark.test.ts b/electron/lib/chat-agent-benchmark.test.ts index 6d2e2497..55c3a70e 100644 --- a/electron/lib/chat-agent-benchmark.test.ts +++ b/electron/lib/chat-agent-benchmark.test.ts @@ -41,7 +41,7 @@ import { updateNote, updateCard, createTag, } from "../db/queries"; import { TOOLS, type ChatRequest } from "./tools"; -import { normaliseBaseUrl } from "./llm"; +import { BASE_URL, MODEL, API_KEY, endpointUp } from "./bench-endpoint"; import { runToolLoop } from "./chat-loop"; import { runDispatchLoop, RESEARCH_TOOL_NAMES, WRITE_TOOL_NAMES, type SubagentMetrics, @@ -49,12 +49,6 @@ import { // ── Endpoint config ───────────────────────────────────────────────────────── -const BASE_URL = normaliseBaseUrl( - process.env.TEST_LLM_BASE_URL?.trim() || "http://localhost:1234/v1", -); -const MODEL = process.env.TEST_LLM_MODEL?.trim() || "gpt-4o-mini"; -const API_KEY = process.env.TEST_LLM_API_KEY?.trim() || ""; - const tok = (s: string) => encode(s).length; /** Serialise a tool the way the API sees it, for token accounting. */ @@ -70,18 +64,6 @@ function toolArrayTokens(names?: ReadonlySet): number { return list.reduce((sum, t) => sum + tok(toolStr(t)), 0); } -async function endpointUp(): Promise { - try { - const res = await fetch(`${BASE_URL}/v1/models`, { - headers: API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}, - signal: AbortSignal.timeout(2500), - }); - return res.ok || res.status === 401 || res.status === 404; - } catch { - return false; - } -} - // ── Seed a representative workspace ─────────────────────────────────────────── function makeDb() { diff --git a/electron/lib/chat-quality-scorer.test.ts b/electron/lib/chat-quality-scorer.test.ts index 2be2603c..05ff7901 100644 --- a/electron/lib/chat-quality-scorer.test.ts +++ b/electron/lib/chat-quality-scorer.test.ts @@ -37,13 +37,10 @@ import { } from "../db/queries"; import { buildSystemPrompt, type ChatRequest } from "./tools"; import { normaliseBaseUrl } from "./llm"; +import { BASE_URL, MODEL, API_KEY, endpointUp } from "./bench-endpoint"; import { runToolLoop } from "./chat-loop"; import { runDispatchLoop } from "./chat-subagent-loop"; -const BASE_URL = normaliseBaseUrl(process.env.TEST_LLM_BASE_URL?.trim() || "http://localhost:1234/v1"); -const MODEL = process.env.TEST_LLM_MODEL?.trim() || "gpt-4o-mini"; -const API_KEY = process.env.TEST_LLM_API_KEY?.trim() || ""; - // Judge endpoint — defaults to the agent endpoint if not overridden. const JUDGE_BASE_URL = normaliseBaseUrl(process.env.JUDGE_BASE_URL?.trim() || BASE_URL); const JUDGE_MODEL = process.env.JUDGE_MODEL?.trim() || MODEL; @@ -130,13 +127,6 @@ function captureNew(db: Database.Database, beforeNoteIds: Set, beforeCar return { notes, cards }; } -async function endpointUp(url: string, key: string): Promise { - try { - const res = await fetch(`${url}/v1/models`, { headers: key ? { Authorization: `Bearer ${key}` } : {}, signal: AbortSignal.timeout(2500) }); - return res.ok || res.status === 401 || res.status === 404; - } catch { return false; } -} - // ── The judge ───────────────────────────────────────────────────────────────── interface RubricScore { task_completion: number; theme_coverage: number; faithfulness: number; structure: number; actionability: number; comment: string; } diff --git a/electron/lib/chat-subagent-loop.ts b/electron/lib/chat-subagent-loop.ts index cdcec214..934037b2 100644 --- a/electron/lib/chat-subagent-loop.ts +++ b/electron/lib/chat-subagent-loop.ts @@ -247,6 +247,7 @@ async function runSubagent( argMutator?: (name: string, args: Record) => Record, childId?: string, events?: SubagentEvents, + signal?: AbortSignal, ): Promise { metrics.subagentRuns += 1; const messages: OpenAIMessage[] = [ @@ -266,7 +267,7 @@ async function runSubagent( metrics.toolCalls += 1; if (childId) events?.onSubagentToolCall?.({ childId, tool: e.tool, label: e.label, args: e.args, callId: e.callId }); }, - undefined, + signal, getWin, cfg.provider, (pt, ct, rt) => { @@ -300,7 +301,7 @@ async function runSubagent( // explicit nudge to write the findings. This is the standard "force final // answer" pattern and rescues most empty-output cases on small models. if (metrics.toolCalls > 0 || messages.some((m) => m.role === "tool")) { - const forced = await forceFinalAnswer(cfg, messages, metrics); + const forced = await forceFinalAnswer(cfg, messages, metrics, signal); if (forced.trim()) { if (childId) events?.onSubagentToken?.({ childId, delta: forced.trim() }); return forced.trim(); @@ -319,6 +320,7 @@ async function forceFinalAnswer( cfg: DispatchConfig, messages: OpenAIMessage[], metrics: SubagentMetrics, + signal?: AbortSignal, ): Promise { const nudged: OpenAIMessage[] = [ ...messages, @@ -338,6 +340,7 @@ async function forceFinalAnswer( const response = await fetch(`${cfg.baseUrl}/v1/chat/completions`, { method: "POST", headers, + signal, body: JSON.stringify({ model: cfg.model, messages: nudged, @@ -376,6 +379,8 @@ export interface DispatchOptions { argMutator?: (name: string, args: Record) => Record; /** Streaming callbacks for a live, expandable subagent trace in the UI. */ events?: SubagentEvents; + /** Abort signal so user cancellation stops every model/tool fetch. */ + signal?: AbortSignal; } export async function runDispatchLoop( @@ -390,6 +395,7 @@ export async function runDispatchLoop( const researchTools = opts?.researchTools ?? RESEARCH_TOOL_NAMES; const argMutator = opts?.argMutator; const events = opts?.events; + const signal = opts?.signal; let subSeq = 0; const date = new Date().toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric", @@ -420,23 +426,35 @@ export async function runDispatchLoop( let finalReasoning = ""; for (let round = 0; round < maxSteps; round++) { + if (signal?.aborted) { + return { content: finalContent, reasoning: finalReasoning, metrics }; + } const headers: Record = { "Content-Type": "application/json" }; if (cfg.apiKey) headers["Authorization"] = `Bearer ${cfg.apiKey}`; - const response = await fetch(`${cfg.baseUrl}/v1/chat/completions`, { - method: "POST", - headers, - body: JSON.stringify({ - model: cfg.model, - messages, - tools: DISPATCH_TOOLS, - tool_choice: "auto", - max_tokens: 4096, - temperature, - stream: false, - stream_options: undefined, - }), - }); + let response: Response; + try { + response = await fetch(`${cfg.baseUrl}/v1/chat/completions`, { + method: "POST", + headers, + signal, + body: JSON.stringify({ + model: cfg.model, + messages, + tools: DISPATCH_TOOLS, + tool_choice: "auto", + max_tokens: 4096, + temperature, + stream: false, + stream_options: undefined, + }), + }); + } catch (_err) { + if (signal?.aborted) return { content: finalContent, reasoning: finalReasoning, metrics }; + // Match the single-agent loop's graceful network-failure shape rather than + // letting the fetch rejection escape the dispatch loop. + return { content: `Could not reach the AI endpoint at \`${cfg.baseUrl}\`. Check your endpoint URL and make sure the server is running.`, reasoning: finalReasoning, metrics }; + } if (!response.ok) { const errText = await response.text().catch(() => response.statusText); @@ -479,7 +497,7 @@ export async function runDispatchLoop( // subagents did real work. Force a final synthesis from the gathered // subagent results rather than returning nothing. if (!finalContent && metrics.subagentRuns > 0) { - finalContent = (await forceFinalAnswer(cfg, messages, metrics)).trim(); + finalContent = (await forceFinalAnswer(cfg, messages, metrics, signal)).trim(); } return { content: finalContent, reasoning: finalReasoning, metrics }; } @@ -498,7 +516,7 @@ export async function runDispatchLoop( events?.onSubagentStart?.({ childId, role: "research", instruction }); subResult = await runSubagent( db, req, workspacePath, cfg, RESEARCH_SYSTEM_PROMPT(date), - instruction, researchTools, metrics, getWin, argMutator, childId, events, + instruction, researchTools, metrics, getWin, argMutator, childId, events, signal, ); events?.onSubagentDone?.({ childId, role: "research", result: subResult }); } else if (call.function.name === "write") { @@ -507,7 +525,7 @@ export async function runDispatchLoop( events?.onSubagentStart?.({ childId, role: "write", instruction }); subResult = await runSubagent( db, req, workspacePath, cfg, WRITE_SYSTEM_PROMPT(date), - instruction, writeTools, metrics, getWin, argMutator, childId, events, + instruction, writeTools, metrics, getWin, argMutator, childId, events, signal, ); events?.onSubagentDone?.({ childId, role: "write", result: subResult }); } else { diff --git a/electron/lib/chat-subagent-stream.test.ts b/electron/lib/chat-subagent-stream.test.ts index 042b3a27..f1f0228d 100644 --- a/electron/lib/chat-subagent-stream.test.ts +++ b/electron/lib/chat-subagent-stream.test.ts @@ -17,21 +17,10 @@ import os from "os"; import path from "path"; import { applySchema } from "../db/schema"; import { createWorkspace, createProject, createColumn, createNote } from "../db/queries"; -import { normaliseBaseUrl } from "./llm"; +import { BASE_URL, MODEL, API_KEY, endpointUp } from "./bench-endpoint"; import { runDispatchLoop, type SubagentEvents } from "./chat-subagent-loop"; import type { ChatRequest } from "./tools"; -const BASE_URL = normaliseBaseUrl(process.env.TEST_LLM_BASE_URL?.trim() || "http://localhost:1234/v1"); -const MODEL = process.env.TEST_LLM_MODEL?.trim() || "gpt-4o-mini"; -const API_KEY = process.env.TEST_LLM_API_KEY?.trim() || ""; - -async function endpointUp(): Promise { - try { - const res = await fetch(`${BASE_URL}/v1/models`, { headers: API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}, signal: AbortSignal.timeout(2500) }); - return res.ok || res.status === 401 || res.status === 404; - } catch { return false; } -} - describe.skipIf(!!process.env.CAIRN_SKIP_LIVE_TESTS)("subagent chat streaming (live smoke)", () => { let up = false; beforeEach(async () => { up = await endpointUp(); }); diff --git a/electron/lib/chat-write-recovery-benchmark.test.ts b/electron/lib/chat-write-recovery-benchmark.test.ts index c3012fe5..c8dfc1d8 100644 --- a/electron/lib/chat-write-recovery-benchmark.test.ts +++ b/electron/lib/chat-write-recovery-benchmark.test.ts @@ -34,7 +34,7 @@ import path from "path"; import { applySchema } from "../db/schema"; import { createWorkspace, createProject, createColumn, createNote, getNoteById } from "../db/queries"; import { buildSystemPrompt, type ChatRequest } from "./tools"; -import { normaliseBaseUrl } from "./llm"; +import { BASE_URL, MODEL, API_KEY, endpointUp } from "./bench-endpoint"; import { runToolLoop } from "./chat-loop"; import { runDispatchLoop, WRITE_TOOL_NAMES, WRITE_TOOL_NAMES_STRICT } from "./chat-subagent-loop"; @@ -45,10 +45,6 @@ const noAppend = (s: ReadonlySet) => new Set([...s].filter((n) => n !== const HYBRID_WRITE = noAppend(WRITE_TOOL_NAMES); // still has get_note const STRICT_WRITE = noAppend(WRITE_TOOL_NAMES_STRICT); // no read tools at all -const BASE_URL = normaliseBaseUrl(process.env.TEST_LLM_BASE_URL?.trim() || "http://localhost:1234/v1"); -const MODEL = process.env.TEST_LLM_MODEL?.trim() || "gpt-4o-mini"; -const API_KEY = process.env.TEST_LLM_API_KEY?.trim() || ""; - const WS = "ws-fault"; const PROJ = "proj-fault"; const COL = "col-fault"; @@ -89,15 +85,6 @@ function seed(db: Database.Database) { createNote(db, { id: NOTE_ID, projectId: PROJ, workspaceId: WS, title: NOTE_TITLE, content: NOTE_BODY }); } -async function endpointUp(): Promise { - try { - const res = await fetch(`${BASE_URL}/v1/models`, { - headers: API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}, - signal: AbortSignal.timeout(2500), - }); - return res.ok || res.status === 401 || res.status === 404; - } catch { return false; } -} /** * Fault injector factory. Corrupts the FIRST patch_note call's oldString so it diff --git a/electron/shared/notes-io.ts b/electron/shared/notes-io.ts index 42f042dc..e6600213 100644 --- a/electron/shared/notes-io.ts +++ b/electron/shared/notes-io.ts @@ -342,21 +342,34 @@ function isStaleDuplicate(from: string, to: string): boolean { } /** - * Remove `dir` if it holds no note (`.md`) files, recursively. A merge can leave + * Remove `dir` if it holds no real data, recursively. A merge/rename can leave * behind OS cruft (a macOS `.DS_Store`) or now-empty subdirectories that would * otherwise keep the renamed-away folder alive on disk. We never delete a dir - * that still contains real note data. + * that still contains real data — that includes note (`.md`) files AND any + * other user file (attachments, `.canvas`, images, etc.), so we don't destroy + * non-note content that happens to live in a folder. */ function removeDirIfNoNotesLeft(dir: string): void { try { if (!fs.existsSync(dir)) return; - if (dirContainsNote(dir)) return; // real note data remains — keep it + if (dirContainsRealData(dir)) return; // real data remains — keep it fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best-effort */ } } -/** True if `dir` (recursively) contains at least one `.md` file. */ -function dirContainsNote(dir: string): boolean { +/** + * OS/tooling cruft filenames that never count as user data — safe to delete + * along with an otherwise-empty directory. + */ +const OS_CRUFT_NAMES = new Set([".DS_Store", "Thumbs.db", "desktop.ini", ".localized"]); + +/** + * True if `dir` (recursively) contains at least one real file — i.e. any file + * that is not explicitly-recognised OS cruft. Empty subdirectories and cruft + * files do not count; everything else (notes, attachments, etc.) blocks + * deletion so we never destroy non-note content. + */ +function dirContainsRealData(dir: string): boolean { let entries: string[]; try { entries = fs.readdirSync(dir); @@ -368,8 +381,8 @@ function dirContainsNote(dir: string): boolean { let stat: fs.Stats; try { stat = fs.lstatSync(full); } catch { continue; } if (stat.isDirectory()) { - if (dirContainsNote(full)) return true; - } else if (entry.endsWith(".md")) { + if (dirContainsRealData(full)) return true; + } else if (!OS_CRUFT_NAMES.has(entry)) { return true; } } diff --git a/src/components/kanban/board.tsx b/src/components/kanban/board.tsx index b1391d72..a61d5b31 100644 --- a/src/components/kanban/board.tsx +++ b/src/components/kanban/board.tsx @@ -302,8 +302,9 @@ export function KanbanBoard() { if (!activeCard) { return; } const p = livePointer.current; if (!p) return; - // Highlight a sidebar project row if the pointer is over one. - updateSidebarDropHighlight(p.x, p.y, activeCard.projectId); + // Sidebar project-row highlighting is driven solely by the pointermove/ + // touchmove listeners in the activeCard effect (they have accurate, + // auto-scroll-aware coordinates), so we don't duplicate it here. const barRect = titleBarRef.current?.getBoundingClientRect() ?? null; const archive = archiveBtnRef.current?.getBoundingClientRect() ?? null; const del = deleteBtnRef.current?.getBoundingClientRect() ?? null; diff --git a/src/hooks/useChatStream.ts b/src/hooks/useChatStream.ts index d2f6a457..fdddd65c 100644 --- a/src/hooks/useChatStream.ts +++ b/src/hooks/useChatStream.ts @@ -267,12 +267,17 @@ export function useChatStream(threadId: string | null): UseChatStreamResult { // Only trigger for tools that actually persist state — exclude read-only // tools (get_*/list_*/search_*) and suggestion-only tools that stage // pendingActionsRef without writing to the DB. - const hasPersistedWrite = finalToolCalls.some((tc) => { - const name = tc.tool; + const isWriteTool = (name: string) => { if (name.startsWith("get_") || name.startsWith("list_") || name.startsWith("search_")) return false; if (name === "suggest_connections" || name === "ask_questions") return false; return true; - }); + }; + // In subagent mode, writes happen inside the write sub-agent's tool calls, + // not the top-level list — check both so the board/notes still refresh. + const subagentToolCalls = subagentsRef.current.flatMap((s) => s.toolCalls ?? []); + const hasPersistedWrite = + finalToolCalls.some((tc) => isWriteTool(tc.tool)) || + subagentToolCalls.some((tc) => isWriteTool(tc.tool)); if (hasPersistedWrite) { useCairnStore.getState().hydrateFromElectron(true).catch((err) => { console.error("[useChatStream] post-write hydrate failed", err); diff --git a/src/lib/cross-project-dnd.ts b/src/lib/cross-project-dnd.ts index 7ed60f79..e27ba912 100644 --- a/src/lib/cross-project-dnd.ts +++ b/src/lib/cross-project-dnd.ts @@ -34,30 +34,3 @@ export function setActiveCrossProjectDrag(payload: CrossProjectDragPayload | nul export function getActiveCrossProjectDrag(): CrossProjectDragPayload | null { return active; } - -/** Attach the payload to a native drag event's dataTransfer (best-effort). */ -export function writeCrossProjectDrag( - dt: DataTransfer | null | undefined, - payload: CrossProjectDragPayload, -): void { - setActiveCrossProjectDrag(payload); - try { - dt?.setData(CROSS_PROJECT_DND_MIME, JSON.stringify(payload)); - if (dt) dt.effectAllowed = "move"; - } catch { - /* dataTransfer unavailable — the module mirror still carries the payload */ - } -} - -/** Read the payload from a drop event, falling back to the module mirror. */ -export function readCrossProjectDrag( - dt: DataTransfer | null | undefined, -): CrossProjectDragPayload | null { - try { - const raw = dt?.getData(CROSS_PROJECT_DND_MIME); - if (raw) return JSON.parse(raw) as CrossProjectDragPayload; - } catch { - /* fall through to the module mirror */ - } - return getActiveCrossProjectDrag(); -} diff --git a/src/store/slices/notes.test.ts b/src/store/slices/notes.test.ts index 617febe5..f9cf983d 100644 --- a/src/store/slices/notes.test.ts +++ b/src/store/slices/notes.test.ts @@ -28,8 +28,8 @@ const note = (id: string, folder: string, projectId = "proj-1"): Note => version: 0, } as unknown as Note); -function setup(notes: Note[]) { - let state: any = { persist: () => {}, projects: [{ id: "proj-1", workspaceId: "ws-1", name: "P" }] }; +function setup(notes: Note[], projects: any[] = [{ id: "proj-1", workspaceId: "ws-1", name: "P" }]) { + let state: any = { persist: () => {}, projects }; const mockSet = (updater: any) => { const next = typeof updater === "function" ? updater(state) : updater; state = { ...state, ...next }; @@ -43,6 +43,8 @@ function setup(notes: Note[]) { } const folderOf = (get: () => any, id: string) => get().notes.find((n: Note) => n.id === id).folder; +const projectOf = (get: () => any, id: string) => get().notes.find((n: Note) => n.id === id).projectId; +const workspaceOf = (get: () => any, id: string) => get().notes.find((n: Note) => n.id === id).workspaceId; describe("moveFolder", () => { it("reparents a folder and re-prefixes all descendant notes", () => { @@ -87,3 +89,60 @@ describe("moveFolder", () => { expect(folderOf(get, "x")).toBe("Design"); // different project — untouched }); }); + +describe("moveFolderToProject", () => { + const twoProjects = [ + { id: "proj-1", workspaceId: "ws-1", name: "P1" }, + { id: "proj-2", workspaceId: "ws-2", name: "P2" }, + ]; + + it("moves the whole folder subtree's projectId + workspaceId to the target project", () => { + const { get } = setup( + [ + note("a", "Design", "proj-1"), + note("b", "Design/Typography", "proj-1"), + note("c", "Design/Typography/Deep", "proj-1"), + note("d", "Other", "proj-1"), + ], + twoProjects, + ); + get().moveFolderToProject("proj-1", "Design", "proj-2"); + for (const id of ["a", "b", "c"]) { + expect(projectOf(get, id)).toBe("proj-2"); + expect(workspaceOf(get, id)).toBe("ws-2"); // adopts target project's workspace + expect(folderOf(get, id)).toBe(folderOf(get, id)); // folder path preserved + } + // A note outside the moved subtree is untouched. + expect(projectOf(get, "d")).toBe("proj-1"); + expect(workspaceOf(get, "d")).toBe("ws-1"); + }); + + it("preserves the folder subtree paths after the project move", () => { + const { get } = setup([note("a", "Design/Typography", "proj-1")], twoProjects); + get().moveFolderToProject("proj-1", "Design", "proj-2"); + expect(folderOf(get, "a")).toBe("Design/Typography"); + }); + + it("does nothing when the destination project does not exist", () => { + const { get } = setup([note("a", "Design", "proj-1")], twoProjects); + get().moveFolderToProject("proj-1", "Design", "proj-nope"); + expect(projectOf(get, "a")).toBe("proj-1"); + expect(workspaceOf(get, "a")).toBe("ws-1"); + }); + + it("is a no-op when source and target projects are identical", () => { + const { get } = setup([note("a", "Design", "proj-1")], twoProjects); + get().moveFolderToProject("proj-1", "Design", "proj-1"); + expect(projectOf(get, "a")).toBe("proj-1"); + expect(workspaceOf(get, "a")).toBe("ws-1"); + }); + + it("does not touch notes outside the source project", () => { + const { get } = setup([note("a", "Design", "proj-1"), note("x", "Design", "proj-2")], twoProjects); + get().moveFolderToProject("proj-1", "Design", "proj-2"); + expect(projectOf(get, "a")).toBe("proj-2"); + // "x" was already in proj-2 under the same folder name — must stay put. + expect(projectOf(get, "x")).toBe("proj-2"); + expect(workspaceOf(get, "x")).toBe("ws-1"); // its original workspace, unchanged + }); +}); diff --git a/src/store/slices/notes.ts b/src/store/slices/notes.ts index 35ca9bb8..1140c70f 100644 --- a/src/store/slices/notes.ts +++ b/src/store/slices/notes.ts @@ -19,6 +19,20 @@ import { makeLinkNoteToCardCmd, } from "@/lib/commands/note-commands"; +/** + * Notes in `projectId` that live at `source` (a normalized folder path) or any + * descendant of it. Folder matching is case-insensitive. Shared by moveFolder + * and moveFolderToProject so their subtree selection stays identical. + */ +function notesInFolderSubtree(notes: Note[], projectId: ID, source: string): Note[] { + const srcLower = source.toLowerCase(); + return notes.filter((n) => { + if (n.projectId !== projectId) return false; + const f = normalizeFolderPath(n.folder).toLowerCase(); + return f === srcLower || f.startsWith(`${srcLower}/`); + }); +} + // ── Slice interface ─────────────────────────────────────────────────────────── /** @@ -216,12 +230,7 @@ export const createNotesSlice: StateCreator = ( // would nest the folder inside itself and orphan the notes). if (newBase === source || newBase.startsWith(`${source}/`)) return; - const srcLower = source.toLowerCase(); - const affected = get().notes.filter((n) => { - if (n.projectId !== projectId) return false; - const f = normalizeFolderPath(n.folder).toLowerCase(); - return f === srcLower || f.startsWith(`${srcLower}/`); - }); + const affected = notesInFolderSubtree(get().notes, projectId, source); if (affected.length === 0) return; const nowTs = now(); @@ -252,12 +261,7 @@ export const createNotesSlice: StateCreator = ( const targetProject = get().projects.find((p) => p.id === targetProjectId); if (!targetProject) return; - const srcLower = source.toLowerCase(); - const affected = get().notes.filter((n) => { - if (n.projectId !== sourceProjectId) return false; - const f = normalizeFolderPath(n.folder).toLowerCase(); - return f === srcLower || f.startsWith(`${srcLower}/`); - }); + const affected = notesInFolderSubtree(get().notes, sourceProjectId, source); if (affected.length === 0) return; const nowTs = now();