refactor: consolidate ai agent session flow

This commit is contained in:
lucaronin
2026-04-28 04:20:40 +02:00
parent c72d833624
commit db82aee172
23 changed files with 377 additions and 957 deletions

View File

@@ -222,7 +222,7 @@ Notes can be opened in separate Tauri windows for focused editing. Secondary win
Full agent mode — spawns the selected local CLI agent as a subprocess with tool access and MCP vault integration.
1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgents.ts`) — streaming UI with reasoning blocks, tool action cards, response display, onboarding, and default-agent selection
1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgentSession.ts` + `aiAgents.ts`) — one normalized session lifecycle for message state, reasoning blocks, tool action cards, response display, onboarding, and default-agent selection
2. **Backend** (`ai_agents.rs`) — normalizes agent availability and streaming, dispatching to per-agent adapters
3. **Agent adapters** — Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, a file/search-only built-in tool list, hidden Windows subprocess launches, and closed stdin for print-mode subprocesses so Windows launches receive EOF; Codex runs through `codex --sandbox workspace-write --ask-for-approval never exec --json`; OpenCode runs through `opencode run --format json`; Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`. OpenCode and Pi both launch from the active vault cwd with closed stdin and transient MCP config. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags.
4. **MCP Integration** — Claude receives the generated MCP config file path, Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides, OpenCode receives it through `OPENCODE_CONFIG_CONTENT`, and Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter`
@@ -268,7 +268,7 @@ sequenceDiagram
#### File Operation Detection
When the agent writes or edits vault files, `useCliAiAgent` detects this from normalized tool inputs and calls `onFileCreated` or `onFileModified` callbacks to trigger vault reload.
When the agent writes or edits vault files, `aiAgentFileOperations.ts` detects this from normalized tool inputs and calls `onFileCreated` or `onFileModified` callbacks to trigger vault reload. Unrecognized write-like operations fall back to a full vault refresh.
### Context Building
@@ -718,10 +718,9 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| Command | Description |
|---------|-------------|
| `stream_claude_chat` | Claude CLI chat mode (streaming) |
| `stream_claude_agent` | Claude CLI agent mode (streaming + tools) |
| `check_claude_cli` | Check if Claude CLI is available |
| `get_ai_agents_status` | Check Claude Code + Codex + OpenCode + Pi availability |
| `stream_ai_agent` | Stream the selected CLI agent through the normalized event layer |
| `stream_ai_agent` | Stream Claude Code, Codex, OpenCode, or Pi through the normalized agent event layer |
| `register_mcp_tools` | Register MCP in Claude/Cursor/generic config for the active vault |
| `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Cursor/generic config |
| `check_mcp_status` | Check whether the active vault is explicitly registered in Claude/Cursor/generic config |
@@ -783,7 +782,7 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
| `useTabManagement` | Navigation history, note switching | Note navigation lifecycle |
| `useVaultSwitcher` | `vaultPath`, `extraVaults` | Vault switching |
| `useTheme` | Editor theme CSS vars and theme-mode bridge | Editor typography and app theme runtime |
| `useCliAiAgent` | `messages`, `status`, tool actions | Selected AI agent conversation |
| `useCliAiAgent` | `messages`, `status`, tool actions | Selected AI agent conversation backed by the shared session pipeline |
| `useAutoSync` | Sync interval, pull/push state | Git auto-sync |
| `useAutoGit` | Last activity timestamp, idle/inactive checkpoint triggers | Automatic commit/push checkpoints |
| `useCommitFlow` | Commit dialog state, shared manual/automatic checkpoint runner | Git commit/push orchestration |

View File

@@ -106,15 +106,14 @@ tolaria/
│ │ └── ui/ # shadcn/ui primitives
│ │ ├── button.tsx, dialog.tsx, input.tsx, ...
│ │
│ ├── hooks/ # Custom React hooks (~87 files)
│ ├── hooks/ # Custom React hooks (~86 files)
│ │ ├── useVaultLoader.ts # Loads vault entries + content
│ │ ├── useVaultSwitcher.ts # Multi-vault management
│ │ ├── useVaultConfig.ts # Per-vault UI settings
│ │ ├── useNoteActions.ts # Composes creation + rename + frontmatter
│ │ ├── useNoteCreation.ts # Note/type creation
│ │ ├── useNoteRename.ts # Note renaming + wikilink updates
│ │ ├── useAiAgent.ts # Legacy Claude-specific stream helpers reused by the shared agent hook
│ │ ├── useCliAiAgent.ts # Selected AI agent state + normalized tool tracking
│ │ ├── useCliAiAgent.ts # Selected AI agent state + normalized session pipeline
│ │ ├── useAiAgentsStatus.ts # Claude/Codex/OpenCode/Pi availability polling
│ │ ├── useAiAgentPreferences.ts # Default-agent persistence + cycling
│ │ ├── useAiActivity.ts # MCP UI bridge listener
@@ -284,7 +283,9 @@ tolaria/
| File | Why it matters |
|------|---------------|
| `src/components/AiPanel.tsx` | AI agent panel — selected CLI agent with tool execution, reasoning, and actions. |
| `src/hooks/useCliAiAgent.ts` | Agent state: messages, streaming, tool tracking, file detection. |
| `src/hooks/useCliAiAgent.ts` | Thin React owner for the selected CLI agent session state. |
| `src/lib/aiAgentSession.ts` | Single message/session lifecycle for prompt normalization, history, streaming, and reset behavior. |
| `src/lib/aiAgentFileOperations.ts` | Detects agent-created or modified vault files from normalized tool inputs. |
| `src/lib/aiAgents.ts` | Supported agent definitions, status normalization, and default-agent helpers. |
| `src/utils/ai-context.ts` | Context snapshot builder for AI conversations. |

View File

@@ -50,7 +50,7 @@ pub struct ChatStreamRequest {
pub session_id: Option<String>,
}
/// Parameters accepted by `stream_claude_agent`.
/// Parameters accepted by Claude Code agent streams.
#[derive(Debug, Deserialize)]
pub struct AgentStreamRequest {
pub message: String,

View File

@@ -1,6 +1,6 @@
#[cfg(desktop)]
use crate::ai_agents::{AiAgentStreamRequest, AiAgentsStatus};
use crate::claude_cli::{AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus};
use crate::claude_cli::{ChatStreamRequest, ClaudeCliStatus};
use crate::vault::VaultAiGuidanceStatus;
use super::expand_tilde;
@@ -81,14 +81,6 @@ define_desktop_stream_command!(
crate::claude_cli::run_chat_stream
);
#[cfg(desktop)]
define_desktop_stream_command!(
stream_claude_agent,
AgentStreamRequest,
"claude-agent-stream",
crate::claude_cli::run_agent_stream
);
#[cfg(desktop)]
define_desktop_stream_command!(
stream_ai_agent,
@@ -140,15 +132,6 @@ pub async fn stream_claude_chat(
Err("Claude CLI is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn stream_claude_agent(
_app_handle: tauri::AppHandle,
_request: AgentStreamRequest,
) -> Result<String, String> {
Err("Claude CLI is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn stream_ai_agent(

View File

@@ -401,7 +401,6 @@ macro_rules! app_invoke_handler {
commands::get_vault_ai_guidance_status,
commands::restore_vault_ai_guidance,
commands::stream_claude_chat,
commands::stream_claude_agent,
commands::stream_ai_agent,
commands::reload_vault,
commands::reload_vault_entry,

View File

@@ -57,9 +57,10 @@ describe('AiPanel', () => {
mockClearConversation.mockReset()
})
it('renders panel with AI Chat header', () => {
it('renders panel with the default CLI agent header', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
expect(screen.getByText('AI Chat')).toBeTruthy()
expect(screen.getByText('AI Agent')).toBeTruthy()
expect(screen.getByText('Claude Code')).toBeTruthy()
})
it('renders data-testid ai-panel', () => {
@@ -86,7 +87,7 @@ describe('AiPanel', () => {
it('renders empty state without context', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
expect(screen.getByText('Open a note, then ask the AI about it')).toBeTruthy()
expect(screen.getByText('Open a note, then ask Claude Code about it')).toBeTruthy()
})
it('renders contextual empty state when active entry is provided', () => {
@@ -94,7 +95,7 @@ describe('AiPanel', () => {
render(
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} />
)
expect(screen.getByText('Ask about this note and its linked context')).toBeTruthy()
expect(screen.getByText('Ask Claude Code about this note and its linked context')).toBeTruthy()
})
it('shows context bar with active entry title', () => {

View File

@@ -56,7 +56,6 @@ export function AiPanelView({
}: AiPanelViewProps) {
const defaultAiAgent = providedDefaultAiAgent ?? DEFAULT_AI_AGENT
const defaultAiAgentReady = providedDefaultAiAgentReady ?? true
const useLegacyAiExperience = providedDefaultAiAgent === undefined && providedDefaultAiAgentReady === undefined
const inputRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLElement>(null)
const agentLabel = getAiAgentDefinition(defaultAiAgent).label
@@ -100,7 +99,6 @@ export function AiPanelView({
<AiPanelHeader
agentLabel={agentLabel}
agentReady={defaultAiAgentReady}
legacyCopy={useLegacyAiExperience}
onClose={onClose}
onNewChat={handleNewChat}
/>
@@ -110,7 +108,6 @@ export function AiPanelView({
<AiPanelMessageHistory
agentLabel={agentLabel}
agentReady={defaultAiAgentReady}
legacyCopy={useLegacyAiExperience}
messages={agent.messages}
isActive={isActive}
onOpenNote={onOpenNote}

View File

@@ -10,7 +10,6 @@ import type { VaultEntry } from '../types'
interface AiPanelHeaderProps {
agentLabel: string
agentReady: boolean
legacyCopy: boolean
onClose: () => void
onNewChat: () => void
}
@@ -23,7 +22,6 @@ interface AiPanelContextBarProps {
interface AiPanelMessageHistoryProps {
agentLabel: string
agentReady: boolean
legacyCopy: boolean
messages: AiAgentMessage[]
isActive: boolean
onOpenNote?: (path: string) => void
@@ -58,8 +56,7 @@ function AiPanelEmptyState({
agentLabel,
agentReady,
hasContext,
legacyCopy,
}: Pick<AiPanelMessageHistoryProps, 'agentLabel' | 'agentReady' | 'hasContext' | 'legacyCopy'>) {
}: Pick<AiPanelMessageHistoryProps, 'agentLabel' | 'agentReady' | 'hasContext'>) {
if (!agentReady) {
return (
<div
@@ -85,8 +82,8 @@ function AiPanelEmptyState({
<Robot size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
<p style={{ fontSize: 13, margin: '0 0 4px' }}>
{hasContext
? legacyCopy ? 'Ask about this note and its linked context' : `Ask ${agentLabel} about this note and its linked context`
: legacyCopy ? 'Open a note, then ask the AI about it' : `Open a note, then ask ${agentLabel} about it`
? `Ask ${agentLabel} about this note and its linked context`
: `Open a note, then ask ${agentLabel} about it`
}
</p>
<p style={{ fontSize: 11, margin: 0, opacity: 0.6 }}>
@@ -102,7 +99,6 @@ function AiPanelEmptyState({
export function AiPanelHeader({
agentLabel,
agentReady,
legacyCopy,
onClose,
onNewChat,
}: AiPanelHeaderProps) {
@@ -114,14 +110,12 @@ export function AiPanelHeader({
<Robot size={16} className="shrink-0 text-muted-foreground" />
<div className="flex flex-1 flex-col overflow-hidden">
<span className="text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>
{legacyCopy ? 'AI Chat' : 'AI Agent'}
AI Agent
</span>
<span className="truncate text-[11px] text-muted-foreground">
{agentLabel}
{!agentReady ? ' · not installed' : ''}
</span>
{!legacyCopy && (
<span className="truncate text-[11px] text-muted-foreground">
{agentLabel}
{!agentReady ? ' · not installed' : ''}
</span>
)}
</div>
<button
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
@@ -161,7 +155,6 @@ export function AiPanelContextBar({ activeEntry, linkedCount }: AiPanelContextBa
export function AiPanelMessageHistory({
agentLabel,
agentReady,
legacyCopy,
messages,
isActive,
onOpenNote,
@@ -180,7 +173,6 @@ export function AiPanelMessageHistory({
<AiPanelEmptyState
agentLabel={agentLabel}
agentReady={agentReady}
legacyCopy={legacyCopy}
hasContext={hasContext}
/>
)}

View File

@@ -1,366 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { detectFileOperation, parseBashFileCreation, agentMessagesToChatHistory, useAiAgent } from './useAiAgent'
import type { AgentFileCallbacks, AiAgentMessage } from './useAiAgent'
import { streamClaudeAgent } from '../utils/ai-agent'
vi.mock('../utils/ai-agent', () => ({
streamClaudeAgent: vi.fn(),
buildAgentSystemPrompt: vi.fn(() => 'default-system-prompt'),
}))
const mockStreamClaudeAgent = vi.mocked(streamClaudeAgent)
const VAULT = '/Users/luca/Laputa'
function makeCallbacks() {
return {
onFileCreated: vi.fn(),
onFileModified: vi.fn(),
onVaultChanged: vi.fn(),
} satisfies AgentFileCallbacks
}
describe('detectFileOperation', () => {
it('calls onFileCreated for Write tool with .md in vault', () => {
const cb = makeCallbacks()
detectFileOperation('Write', JSON.stringify({ file_path: `${VAULT}/note/test.md` }), VAULT, cb)
expect(cb.onFileCreated).toHaveBeenCalledWith('note/test.md')
expect(cb.onFileModified).not.toHaveBeenCalled()
})
it('calls onFileModified for Edit tool with .md in vault', () => {
const cb = makeCallbacks()
detectFileOperation('Edit', JSON.stringify({ file_path: `${VAULT}/note/test.md` }), VAULT, cb)
expect(cb.onFileModified).toHaveBeenCalledWith('note/test.md')
expect(cb.onFileCreated).not.toHaveBeenCalled()
})
it('ignores non-md files', () => {
const cb = makeCallbacks()
detectFileOperation('Write', JSON.stringify({ file_path: `${VAULT}/image.png` }), VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
})
it('ignores files outside vault', () => {
const cb = makeCallbacks()
detectFileOperation('Write', JSON.stringify({ file_path: '/tmp/other.md' }), VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
})
it('ignores unknown tool names', () => {
const cb = makeCallbacks()
detectFileOperation('Read', JSON.stringify({ file_path: `${VAULT}/note/test.md` }), VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
expect(cb.onFileModified).not.toHaveBeenCalled()
})
it('calls onVaultChanged when Write input is undefined', () => {
const cb = makeCallbacks()
detectFileOperation('Write', undefined, VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
expect(cb.onVaultChanged).toHaveBeenCalled()
})
it('calls onVaultChanged when Edit input is undefined', () => {
const cb = makeCallbacks()
detectFileOperation('Edit', undefined, VAULT, cb)
expect(cb.onFileModified).not.toHaveBeenCalled()
expect(cb.onVaultChanged).toHaveBeenCalled()
})
it('calls onVaultChanged when Write has malformed JSON input', () => {
const cb = makeCallbacks()
detectFileOperation('Write', 'not-json', VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
expect(cb.onVaultChanged).toHaveBeenCalled()
})
it('does not call onVaultChanged when Write detects specific file', () => {
const cb = makeCallbacks()
detectFileOperation('Write', JSON.stringify({ file_path: `${VAULT}/note/test.md` }), VAULT, cb)
expect(cb.onFileCreated).toHaveBeenCalledWith('note/test.md')
expect(cb.onVaultChanged).not.toHaveBeenCalled()
})
it('does not call onVaultChanged for Read tool', () => {
const cb = makeCallbacks()
detectFileOperation('Read', undefined, VAULT, cb)
expect(cb.onVaultChanged).not.toHaveBeenCalled()
})
it('calls onVaultChanged when Bash input is undefined', () => {
const cb = makeCallbacks()
detectFileOperation('Bash', undefined, VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
expect(cb.onVaultChanged).toHaveBeenCalled()
})
it('handles undefined callbacks gracefully', () => {
expect(() => detectFileOperation('Write', JSON.stringify({ file_path: `${VAULT}/note/test.md` }), VAULT, undefined)).not.toThrow()
})
it('detects Bash redirect creating .md file in vault', () => {
const cb = makeCallbacks()
detectFileOperation('Bash', JSON.stringify({ command: `echo "# Note" > ${VAULT}/note/new.md` }), VAULT, cb)
expect(cb.onFileCreated).toHaveBeenCalledWith('note/new.md')
})
it('detects Bash append redirect creating .md file', () => {
const cb = makeCallbacks()
detectFileOperation('Bash', JSON.stringify({ command: `cat content.txt >> ${VAULT}/daily/2026-03-07.md` }), VAULT, cb)
expect(cb.onFileCreated).toHaveBeenCalledWith('daily/2026-03-07.md')
})
it('detects Bash tee command creating .md file', () => {
const cb = makeCallbacks()
detectFileOperation('Bash', JSON.stringify({ command: `echo "content" | tee ${VAULT}/note/tee-note.md` }), VAULT, cb)
expect(cb.onFileCreated).toHaveBeenCalledWith('note/tee-note.md')
})
it('ignores Bash commands without file creation', () => {
const cb = makeCallbacks()
detectFileOperation('Bash', JSON.stringify({ command: 'ls -la' }), VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
})
it('ignores Bash creating non-md files', () => {
const cb = makeCallbacks()
detectFileOperation('Bash', JSON.stringify({ command: `echo "data" > ${VAULT}/config.json` }), VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
})
it('ignores Bash creating .md files outside vault', () => {
const cb = makeCallbacks()
detectFileOperation('Bash', JSON.stringify({ command: 'echo "text" > /tmp/other.md' }), VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
})
it('uses path field when file_path is missing', () => {
const cb = makeCallbacks()
detectFileOperation('Write', JSON.stringify({ path: `${VAULT}/note/alt.md` }), VAULT, cb)
expect(cb.onFileCreated).toHaveBeenCalledWith('note/alt.md')
})
})
describe('parseBashFileCreation', () => {
it('returns null for undefined input', () => {
expect(parseBashFileCreation(undefined, VAULT)).toBeNull()
})
it('returns null for malformed JSON', () => {
expect(parseBashFileCreation('not json', VAULT)).toBeNull()
})
it('returns null when no command field', () => {
expect(parseBashFileCreation(JSON.stringify({ other: 'value' }), VAULT)).toBeNull()
})
it('returns null when command is not a string', () => {
expect(parseBashFileCreation(JSON.stringify({ command: 42 }), VAULT)).toBeNull()
})
it('parses simple redirect', () => {
const input = JSON.stringify({ command: `echo "# Title" > ${VAULT}/note.md` })
expect(parseBashFileCreation(input, VAULT)).toBe('note.md')
})
it('parses append redirect', () => {
const input = JSON.stringify({ command: `echo "line" >> ${VAULT}/sub/note.md` })
expect(parseBashFileCreation(input, VAULT)).toBe('sub/note.md')
})
it('parses tee command', () => {
const input = JSON.stringify({ command: `echo "data" | tee ${VAULT}/new.md` })
expect(parseBashFileCreation(input, VAULT)).toBe('new.md')
})
it('parses tee -a (append) command', () => {
const input = JSON.stringify({ command: `echo "extra" | tee -a ${VAULT}/new.md` })
expect(parseBashFileCreation(input, VAULT)).toBe('new.md')
})
it('returns null for non-md redirect', () => {
const input = JSON.stringify({ command: `echo "x" > ${VAULT}/file.txt` })
expect(parseBashFileCreation(input, VAULT)).toBeNull()
})
})
describe('useAiAgent', () => {
beforeEach(() => {
mockStreamClaudeAgent.mockReset()
mockStreamClaudeAgent.mockImplementation(async (_msg: string, _sys: string, _vault: string, callbacks: { onDone: () => void }) => {
callbacks.onDone()
})
})
it('updates sendMessage when contextPrompt changes (no stale closure)', () => {
// Mount with empty contextPrompt (simulates race: panel mounts before tab content loads)
const { result, rerender } = renderHook(
({ vault, context }) => useAiAgent(vault, context),
{ initialProps: { vault: VAULT, context: undefined as string | undefined } },
)
const firstSendMessage = result.current.sendMessage
// Simulate tab content arriving — re-render with real contextPrompt
rerender({ vault: VAULT, context: 'You are viewing note with body: Hello world' })
// sendMessage must be a NEW function that closes over the updated contextPrompt.
// If it's the same reference, it may read stale context (the race condition).
expect(result.current.sendMessage).not.toBe(firstSendMessage)
})
it('uses the current contextPrompt at send time, not the mount-time value', async () => {
const { result, rerender } = renderHook(
({ vault, context }) => useAiAgent(vault, context),
{ initialProps: { vault: VAULT, context: undefined as string | undefined } },
)
rerender({ vault: VAULT, context: 'You are viewing note with body: Hello world' })
await act(async () => {
await result.current.sendMessage('What does this note contain?')
})
expect(mockStreamClaudeAgent).toHaveBeenCalledTimes(1)
expect(mockStreamClaudeAgent.mock.calls[0][1]).toBe('You are viewing note with body: Hello world')
})
it('falls back to buildAgentSystemPrompt when contextPrompt is undefined', async () => {
const { result } = renderHook(() => useAiAgent(VAULT, undefined))
await act(async () => {
await result.current.sendMessage('Hello')
})
expect(mockStreamClaudeAgent).toHaveBeenCalledTimes(1)
expect(mockStreamClaudeAgent.mock.calls[0][1]).toBe('default-system-prompt')
})
it('sends first message without conversation history', async () => {
const { result } = renderHook(() => useAiAgent(VAULT, undefined))
await act(async () => {
await result.current.sendMessage('Hello')
})
expect(mockStreamClaudeAgent).toHaveBeenCalledTimes(1)
const sentMessage = mockStreamClaudeAgent.mock.calls[0][0]
expect(sentMessage).toBe('Hello')
expect(sentMessage).not.toContain('<conversation_history>')
})
it('embeds conversation history in second message', async () => {
// Mock that simulates a response for the first message
mockStreamClaudeAgent.mockImplementation(async (_msg, _sys, _vault, callbacks) => {
callbacks.onText('The answer is 4')
callbacks.onDone()
})
const { result } = renderHook(() => useAiAgent(VAULT, undefined))
// First message
await act(async () => {
await result.current.sendMessage('What is 2+2?')
})
// Second message — should include history from first exchange
await act(async () => {
await result.current.sendMessage('What was my previous question?')
})
expect(mockStreamClaudeAgent).toHaveBeenCalledTimes(2)
// First call: no history
expect(mockStreamClaudeAgent.mock.calls[0][0]).toBe('What is 2+2?')
// Second call: includes history
const secondMsg = mockStreamClaudeAgent.mock.calls[1][0]
expect(secondMsg).toContain('<conversation_history>')
expect(secondMsg).toContain('What is 2+2?')
expect(secondMsg).toContain('The answer is 4')
expect(secondMsg).toContain('What was my previous question?')
})
it('accumulates history across multiple exchanges', async () => {
let callCount = 0
mockStreamClaudeAgent.mockImplementation(async (_msg, _sys, _vault, callbacks) => {
callCount++
callbacks.onText(`Response ${callCount}`)
callbacks.onDone()
})
const { result } = renderHook(() => useAiAgent(VAULT, undefined))
await act(async () => { await result.current.sendMessage('Q1') })
await act(async () => { await result.current.sendMessage('Q2') })
await act(async () => { await result.current.sendMessage('Q3') })
expect(mockStreamClaudeAgent).toHaveBeenCalledTimes(3)
// First: no history
expect(mockStreamClaudeAgent.mock.calls[0][0]).toBe('Q1')
// Second: history from first exchange
const msg2 = mockStreamClaudeAgent.mock.calls[1][0]
expect(msg2).toContain('Q1')
expect(msg2).toContain('Response 1')
expect(msg2).toContain('Q2')
// Third: history from both exchanges
const msg3 = mockStreamClaudeAgent.mock.calls[2][0]
expect(msg3).toContain('Q1')
expect(msg3).toContain('Response 1')
expect(msg3).toContain('Q2')
expect(msg3).toContain('Response 2')
expect(msg3).toContain('Q3')
})
it('resets history after clearConversation', async () => {
mockStreamClaudeAgent.mockImplementation(async (_msg, _sys, _vault, callbacks) => {
callbacks.onText('reply')
callbacks.onDone()
})
const { result } = renderHook(() => useAiAgent(VAULT, undefined))
await act(async () => { await result.current.sendMessage('hello') })
act(() => { result.current.clearConversation() })
await act(async () => { await result.current.sendMessage('fresh start') })
const lastCall = mockStreamClaudeAgent.mock.calls[mockStreamClaudeAgent.mock.calls.length - 1]
expect(lastCall[0]).toBe('fresh start')
expect(lastCall[0]).not.toContain('<conversation_history>')
})
})
describe('agentMessagesToChatHistory', () => {
it('returns empty array for no messages', () => {
expect(agentMessagesToChatHistory([])).toEqual([])
})
it('converts agent messages to user/assistant pairs', () => {
const msgs: AiAgentMessage[] = [
{ userMessage: 'Q1', response: 'A1', actions: [], id: 'm1' },
{ userMessage: 'Q2', response: 'A2', actions: [], id: 'm2' },
]
const result = agentMessagesToChatHistory(msgs)
expect(result).toHaveLength(4)
expect(result[0]).toMatchObject({ role: 'user', content: 'Q1' })
expect(result[1]).toMatchObject({ role: 'assistant', content: 'A1' })
expect(result[2]).toMatchObject({ role: 'user', content: 'Q2' })
expect(result[3]).toMatchObject({ role: 'assistant', content: 'A2' })
})
it('skips assistant entry when response is undefined (still streaming)', () => {
const msgs: AiAgentMessage[] = [
{ userMessage: 'Q1', actions: [], id: 'm1', isStreaming: true },
]
const result = agentMessagesToChatHistory(msgs)
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({ role: 'user', content: 'Q1' })
})
})

View File

@@ -1,359 +0,0 @@
/**
* Hook for the AI agent panel — manages agent state and streaming.
* Uses Claude CLI subprocess with full tool access + MCP tools via Tauri.
*
* States: idle -> thinking -> tool-executing -> done/error
*
* Reasoning streams live while Claude thinks, then auto-collapses.
* Response text accumulates internally and is revealed as a complete block on done.
*
* Detects file operations (Write/Edit/Bash) and notifies the parent via callbacks
* so the Tolaria UI can auto-open new notes and live-refresh modified notes.
*/
import { useState, useCallback, useRef, useEffect } from 'react'
import type { AiAction } from '../components/AiMessage'
import type { NoteReference } from '../utils/ai-context'
import { streamClaudeAgent, buildAgentSystemPrompt } from '../utils/ai-agent'
import {
nextMessageId, trimHistory, formatMessageWithHistory,
type ChatMessage, MAX_HISTORY_TOKENS,
} from '../utils/ai-chat'
export type AgentStatus = 'idle' | 'thinking' | 'tool-executing' | 'done' | 'error'
export interface AiAgentMessage {
userMessage: string
references?: NoteReference[]
reasoning?: string
reasoningDone?: boolean
actions: AiAction[]
response?: string
isStreaming?: boolean
id?: string
}
export interface AgentFileCallbacks {
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
/** Fallback: vault may have changed but we can't determine the specific file. */
onVaultChanged?: () => void
}
/** Convert completed agent messages to ChatMessage pairs for history embedding. */
export function agentMessagesToChatHistory(msgs: AiAgentMessage[]): ChatMessage[] {
const history: ChatMessage[] = []
for (const msg of msgs) {
history.push({ role: 'user', content: msg.userMessage, id: msg.id ?? '' })
if (msg.response) {
history.push({ role: 'assistant', content: msg.response, id: `${msg.id}-resp` })
}
}
return history
}
export function useAiAgent(
vaultPath: string,
contextPrompt?: string,
fileCallbacks?: AgentFileCallbacks,
) {
const [messages, setMessages] = useState<AiAgentMessage[]>([])
const [status, setStatus] = useState<AgentStatus>('idle')
const abortRef = useRef({ aborted: false })
const responseAccRef = useRef('')
const fileCallbacksRef = useRef(fileCallbacks)
// Track tool inputs for file-operation detection on ToolDone
const toolInputMapRef = useRef<Map<string, { tool: string; input?: string }>>(new Map())
// Refs for latest state — avoids stale closures in callbacks.
// Synced via useEffect (runs after render, before next user interaction).
const messagesRef = useRef<AiAgentMessage[]>([])
const statusRef = useRef<AgentStatus>('idle')
useEffect(() => { messagesRef.current = messages }, [messages])
useEffect(() => { statusRef.current = status }, [status])
useEffect(() => {
fileCallbacksRef.current = fileCallbacks
}, [fileCallbacks])
const sendMessage = useCallback(async (text: string, references?: NoteReference[]) => {
const currentStatus = statusRef.current
if (!text.trim() || currentStatus === 'thinking' || currentStatus === 'tool-executing') return
const refs = references && references.length > 0 ? references : undefined
if (!vaultPath) {
setMessages(prev => [...prev, {
userMessage: text.trim(), references: refs, actions: [],
response: 'No vault loaded. Open a vault first.',
id: nextMessageId(),
}])
return
}
abortRef.current = { aborted: false }
responseAccRef.current = ''
toolInputMapRef.current = new Map()
const messageId = nextMessageId()
setMessages(prev => [...prev, {
userMessage: text.trim(), references: refs, actions: [], isStreaming: true, id: messageId,
}])
setStatus('thinking')
const update = (fn: (m: AiAgentMessage) => AiAgentMessage) => {
setMessages(prev => prev.map(m => m.id === messageId ? fn(m) : m))
}
const markReasoningDone = () => {
update(m => m.reasoningDone ? m : { ...m, reasoningDone: true })
}
const systemPrompt = contextPrompt ?? buildAgentSystemPrompt()
// Embed conversation history from previous exchanges.
// Uses messagesRef (not closure-captured messages) to avoid stale closures.
const chatHistory = agentMessagesToChatHistory(messagesRef.current.filter(m => !m.isStreaming))
const trimmedHistory = trimHistory(chatHistory, MAX_HISTORY_TOKENS)
const formattedMessage = formatMessageWithHistory(trimmedHistory, text.trim())
await streamClaudeAgent(formattedMessage, systemPrompt, vaultPath, {
onThinking: (chunk) => {
if (abortRef.current.aborted) return
update(m => ({ ...m, reasoning: (m.reasoning ?? '') + chunk }))
},
onText: (chunk) => {
if (abortRef.current.aborted) return
markReasoningDone()
responseAccRef.current += chunk
},
onToolStart: (toolName, toolId, input) => {
if (abortRef.current.aborted) return
markReasoningDone()
setStatus('tool-executing')
// Preserve accumulated input — tool_progress events arrive with
// input=undefined AFTER the assistant message set the full input.
const prev = toolInputMapRef.current.get(toolId)
toolInputMapRef.current.set(toolId, { tool: toolName, input: input ?? prev?.input })
update(m => {
const existing = m.actions.find(a => a.toolId === toolId)
if (existing) {
return {
...m,
actions: m.actions.map(a =>
a.toolId === toolId ? { ...a, input: input ?? a.input } : a,
),
}
}
return {
...m,
actions: [...m.actions, {
tool: toolName,
toolId,
label: formatToolLabel(toolName, input),
status: 'pending' as const,
input,
}],
}
})
},
onToolDone: (toolId, output) => {
if (abortRef.current.aborted) return
const info = toolInputMapRef.current.get(toolId)
if (info) {
detectFileOperation(info.tool, info.input, vaultPath, fileCallbacksRef.current)
}
update(m => ({
...m,
actions: m.actions.map(a =>
a.toolId === toolId ? { ...a, status: 'done' as const, output } : a,
),
}))
},
onError: (error) => {
if (abortRef.current.aborted) return
setStatus('error')
const partial = responseAccRef.current
update(m => ({
...m,
isStreaming: false,
reasoningDone: true,
response: partial ? `${partial}\n\nError: ${error}` : `Error: ${error}`,
actions: m.actions.map(a =>
a.status === 'pending' ? { ...a, status: 'error' as const } : a,
),
}))
},
onDone: () => {
if (abortRef.current.aborted) return
setStatus('done')
const finalResponse = responseAccRef.current || undefined
update(m => ({
...m,
isStreaming: false,
reasoningDone: true,
response: finalResponse,
actions: m.actions.map(a => a.status === 'pending' ? { ...a, status: 'done' as const } : a),
}))
// Safety net: refresh vault after agent completes in case file changes were missed
fileCallbacksRef.current?.onVaultChanged?.()
},
})
}, [vaultPath, contextPrompt])
const clearConversation = useCallback(() => {
abortRef.current.aborted = true
responseAccRef.current = ''
toolInputMapRef.current = new Map()
setMessages([])
setStatus('idle')
}, [])
return { messages, status, sendMessage, clearConversation }
}
// --- Helpers ---
/** Parse the file_path from a Write or Edit tool input JSON string. */
function parseFilePath(input: string | undefined): string | null {
if (!input) return null
try {
const parsed = JSON.parse(input)
return parsed.file_path ?? parsed.path ?? null
} catch {
return null
}
}
/** Convert absolute path to vault-relative path, or null if outside vault. */
function toVaultRelative(filePath: string, vaultPath: string): string | null {
if (!filePath.startsWith(vaultPath)) return null
const rel = filePath.slice(vaultPath.length).replace(/^\//, '')
return rel || null
}
/** Detect file operations from completed tool calls and notify callbacks. */
export function detectFileOperation(
toolName: string,
input: string | undefined,
vaultPath: string,
callbacks: AgentFileCallbacks | undefined,
) {
if (!callbacks) return
// Handle Bash commands that create/write .md files
if (toolName === 'Bash') {
const mdPath = parseBashFileCreation(input, vaultPath)
if (mdPath) { callbacks.onFileCreated?.(mdPath); return }
// Bash ran but we couldn't detect a specific .md file — still may have changed vault
callbacks.onVaultChanged?.()
return
}
if (toolName !== 'Write' && toolName !== 'Edit') return
const filePath = parseFilePath(input)
if (filePath && filePath.endsWith('.md')) {
const rel = toVaultRelative(filePath, vaultPath)
if (rel) {
if (toolName === 'Write') callbacks.onFileCreated?.(rel)
else callbacks.onFileModified?.(rel)
return
}
}
// Write/Edit completed but couldn't determine target file — trigger vault refresh
callbacks.onVaultChanged?.()
}
/** Detect .md file creation from a Bash command string. */
export function parseBashFileCreation(input: string | undefined, vaultPath: string): string | null {
if (!input) return null
try {
const parsed = JSON.parse(input)
const cmd = parsed.command ?? parsed.cmd
if (typeof cmd !== 'string') return null
// Match redirect patterns: > file.md, >> file.md, tee file.md, cat > file.md
const match = cmd.match(/(?:>|>>|tee\s+(?:-a\s+)?)\s*["']?([^\s"'|;]+\.md)["']?/)
if (!match) return null
const filePath = match[1]
return toVaultRelative(filePath, vaultPath)
} catch {
return null
}
}
/** Generate a human-readable label for a tool call. */
function formatToolLabel(toolName: string, input?: string): string {
// Native Claude Code tools
switch (toolName) {
case 'Bash': {
const cmd = extractBashCommand(input)
return cmd ? `$ ${cmd}` : 'Running command...'
}
case 'Write': {
const fp = parseFilePath(input)
return fp ? `Writing ${basename(fp)}` : 'Writing file...'
}
case 'Edit': {
const fp = parseFilePath(input)
return fp ? `Editing ${basename(fp)}` : 'Editing file...'
}
case 'Read': {
const fp = parseFilePath(input)
return fp ? `Reading ${basename(fp)}` : 'Reading file...'
}
case 'Glob':
return 'Searching files...'
case 'Grep':
return 'Searching content...'
case 'TodoWrite':
return 'Updating plan...'
default:
break
}
// Tolaria MCP tools
const mcpLabels: Record<string, string> = {
search_notes: 'Searching notes',
get_vault_context: 'Loading vault context',
get_note: 'Reading note',
open_note: 'Opening note',
}
if (mcpLabels[toolName]) {
const notePath = parseNotePath(input)
return notePath ? `${mcpLabels[toolName]}: ${basename(notePath)}` : `${mcpLabels[toolName]}...`
}
return `${toolName}...`
}
function extractBashCommand(input: string | undefined): string | null {
if (!input) return null
try {
const parsed = JSON.parse(input)
const cmd = parsed.command ?? parsed.cmd ?? null
if (typeof cmd !== 'string') return null
// Truncate long commands
return cmd.length > 60 ? cmd.slice(0, 57) + '...' : cmd
} catch {
return null
}
}
function parseNotePath(input: string | undefined): string | null {
if (!input) return null
try {
const parsed = JSON.parse(input)
return parsed.path ?? parsed.query ?? null
} catch {
return null
}
}
function basename(filePath: string): string {
return filePath.split('/').pop() ?? filePath
}

View File

@@ -0,0 +1,95 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCliAiAgent } from './useCliAiAgent'
import { streamAiAgent } from '../utils/streamAiAgent'
vi.mock('../utils/streamAiAgent', () => ({
streamAiAgent: vi.fn(),
}))
vi.mock('../utils/ai-agent', () => ({
buildAgentSystemPrompt: vi.fn(() => 'default-system-prompt'),
}))
const mockStreamAiAgent = vi.mocked(streamAiAgent)
const VAULT = '/Users/luca/Laputa'
function renderAgent(contextPrompt: string | undefined = undefined) {
return renderHook(
({ context }) => useCliAiAgent(VAULT, context, undefined, {
agent: 'codex',
agentReady: true,
}),
{ initialProps: { context: contextPrompt } },
)
}
describe('useCliAiAgent', () => {
beforeEach(() => {
vi.clearAllMocks()
mockStreamAiAgent.mockImplementation(async ({ callbacks }) => {
callbacks.onText('reply')
callbacks.onDone()
})
})
it('uses the latest context prompt when sending a message', async () => {
const { result, rerender } = renderAgent()
const firstSendMessage = result.current.sendMessage
rerender({ context: 'You are viewing note with body: Hello world' })
await act(async () => {
await result.current.sendMessage('What does this note contain?')
})
expect(result.current.sendMessage).not.toBe(firstSendMessage)
expect(mockStreamAiAgent).toHaveBeenCalledWith(expect.objectContaining({
systemPrompt: 'You are viewing note with body: Hello world',
}))
})
it('embeds completed conversation history and clears it for a fresh chat', async () => {
let responseNumber = 0
mockStreamAiAgent.mockImplementation(async ({ callbacks }) => {
responseNumber += 1
callbacks.onText(`Response ${responseNumber}`)
callbacks.onDone()
})
const { result } = renderAgent()
await act(async () => { await result.current.sendMessage('Q1') })
await act(async () => { await result.current.sendMessage('Q2') })
const secondMessage = mockStreamAiAgent.mock.calls[1][0].message
expect(secondMessage).toContain('<conversation_history>')
expect(secondMessage).toContain('Q1')
expect(secondMessage).toContain('Response 1')
expect(secondMessage).toContain('Q2')
act(() => { result.current.clearConversation() })
await act(async () => { await result.current.sendMessage('fresh start') })
const freshMessage = mockStreamAiAgent.mock.calls[2][0].message
expect(freshMessage).toBe('fresh start')
expect(freshMessage).not.toContain('<conversation_history>')
})
it('adds a local response instead of streaming when the selected agent is unavailable', async () => {
const { result } = renderHook(() => useCliAiAgent(VAULT, undefined, undefined, {
agent: 'codex',
agentReady: false,
}))
await act(async () => {
await result.current.sendMessage('Help')
})
expect(mockStreamAiAgent).not.toHaveBeenCalled()
expect(result.current.messages).toEqual([expect.objectContaining({
userMessage: 'Help',
response: 'Codex is not available on this machine. Install it or switch the default AI agent in Settings.',
})])
})
})

View File

@@ -2,20 +2,19 @@ import { useEffect, useRef, useState, type Dispatch, type MutableRefObject, type
import type { AiAgentId } from '../lib/aiAgents'
import type { NoteReference } from '../utils/ai-context'
import {
type AgentStatus,
type AiAgentMessage,
} from '../lib/aiAgentConversation'
import type { AgentFileCallbacks } from '../lib/aiAgentFileOperations'
import {
clearAgentConversation,
sendAgentMessage,
type AiAgentSessionRuntime,
} from '../lib/aiAgentSession'
import type { ToolInvocation } from '../lib/aiAgentMessageState'
import {
type AgentFileCallbacks,
type AgentStatus,
} from './useAiAgent'
export type { AgentFileCallbacks, AgentStatus } from './useAiAgent'
export type { AgentFileCallbacks } from '../lib/aiAgentFileOperations'
export type { AgentStatus } from '../lib/aiAgentConversation'
export type { AiAgentMessage } from '../lib/aiAgentConversation'
interface UseCliAiAgentOptions {

View File

@@ -23,6 +23,8 @@ export interface AiAgentMessage {
id?: string
}
export type AgentStatus = 'idle' | 'thinking' | 'tool-executing' | 'done' | 'error'
export interface AgentExecutionContext {
agent: AiAgentId
ready: boolean

View File

@@ -0,0 +1,67 @@
import { describe, expect, it, vi } from 'vitest'
import {
detectFileOperation,
parseBashFileCreation,
type AgentFileCallbacks,
} from './aiAgentFileOperations'
const VAULT = '/Users/luca/Laputa'
function makeCallbacks() {
return {
onFileCreated: vi.fn(),
onFileModified: vi.fn(),
onVaultChanged: vi.fn(),
} satisfies AgentFileCallbacks
}
describe('detectFileOperation', () => {
it('calls onFileCreated for Write tool with .md in vault', () => {
const cb = makeCallbacks()
detectFileOperation({ toolName: 'Write', input: JSON.stringify({ file_path: `${VAULT}/note/test.md` }), vaultPath: VAULT, callbacks: cb })
expect(cb.onFileCreated).toHaveBeenCalledWith('note/test.md')
expect(cb.onFileModified).not.toHaveBeenCalled()
})
it('calls onFileModified for Edit tool with .md in vault', () => {
const cb = makeCallbacks()
detectFileOperation({ toolName: 'Edit', input: JSON.stringify({ file_path: `${VAULT}/note/test.md` }), vaultPath: VAULT, callbacks: cb })
expect(cb.onFileModified).toHaveBeenCalledWith('note/test.md')
expect(cb.onFileCreated).not.toHaveBeenCalled()
})
it('refreshes the vault when a writable tool target cannot be resolved', () => {
const cb = makeCallbacks()
detectFileOperation({ toolName: 'Write', input: 'not-json', vaultPath: VAULT, callbacks: cb })
detectFileOperation({ toolName: 'Edit', vaultPath: VAULT, callbacks: cb })
expect(cb.onVaultChanged).toHaveBeenCalledTimes(2)
})
it('does not treat path prefixes as files inside the vault', () => {
const cb = makeCallbacks()
detectFileOperation({ toolName: 'Write', input: JSON.stringify({ file_path: `${VAULT}-old/note.md` }), vaultPath: VAULT, callbacks: cb })
expect(cb.onFileCreated).not.toHaveBeenCalled()
expect(cb.onVaultChanged).toHaveBeenCalledOnce()
})
it('detects Bash redirects and tee writes for markdown files in the vault', () => {
expect(parseBashFileCreation({ input: JSON.stringify({ command: `echo "# Title" > ${VAULT}/note.md` }), vaultPath: VAULT })).toBe('note.md')
expect(parseBashFileCreation({ input: JSON.stringify({ command: `echo "line" >> ${VAULT}/sub/note.md` }), vaultPath: VAULT })).toBe('sub/note.md')
expect(parseBashFileCreation({ input: JSON.stringify({ command: `echo "data" | tee -a ${VAULT}/new.md` }), vaultPath: VAULT })).toBe('new.md')
})
it('refreshes the vault for Bash when no specific markdown target is found', () => {
const cb = makeCallbacks()
detectFileOperation({ toolName: 'Bash', input: JSON.stringify({ command: 'ls -la' }), vaultPath: VAULT, callbacks: cb })
expect(cb.onFileCreated).not.toHaveBeenCalled()
expect(cb.onVaultChanged).toHaveBeenCalledOnce()
})
it('ignores read-only tools and missing callbacks', () => {
const cb = makeCallbacks()
expect(() => detectFileOperation({ toolName: 'Write', input: JSON.stringify({ file_path: `${VAULT}/note/test.md` }), vaultPath: VAULT })).not.toThrow()
detectFileOperation({ toolName: 'Read', input: JSON.stringify({ file_path: `${VAULT}/note/test.md` }), vaultPath: VAULT, callbacks: cb })
expect(cb.onFileCreated).not.toHaveBeenCalled()
expect(cb.onVaultChanged).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,163 @@
export interface AgentFileCallbacks {
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
onVaultChanged?: () => void
}
export interface AgentFileOperation {
toolName: string
input?: string
vaultPath: string
callbacks?: AgentFileCallbacks
}
export interface BashFileCreationRequest {
input?: string
vaultPath: string
}
interface OperationContext extends BashFileCreationRequest {
callbacks: AgentFileCallbacks
}
interface PathNotification {
relativePath: string | null
callbacks: AgentFileCallbacks
}
interface ToolInputSource {
input?: string
}
interface ToolInputContext extends ToolInputSource {
vaultPath: string
}
interface VaultRelativePathRequest {
filePath: string
vaultPath: string
}
type OperationHandler = (context: OperationContext) => void
const OPERATION_HANDLERS: Record<string, OperationHandler> = {
Bash: notifyBashOperation,
Write: notifyWriteOperation,
Edit: notifyEditOperation,
}
export function detectFileOperation(operation: AgentFileOperation): void {
if (!operation.callbacks) return
OPERATION_HANDLERS[operation.toolName]?.({
input: operation.input,
vaultPath: operation.vaultPath,
callbacks: operation.callbacks,
})
}
function notifyBashOperation(context: OperationContext): void {
notifyCreatedPath({
relativePath: parseBashFileCreation(context),
callbacks: context.callbacks,
})
}
function notifyWriteOperation(context: OperationContext): void {
notifyCreatedPath({
relativePath: markdownPathFromToolInput(context),
callbacks: context.callbacks,
})
}
function notifyEditOperation(context: OperationContext): void {
notifyModifiedPath({
relativePath: markdownPathFromToolInput(context),
callbacks: context.callbacks,
})
}
function notifyCreatedPath({ relativePath, callbacks }: PathNotification): void {
if (relativePath) {
callbacks.onFileCreated?.(relativePath)
} else {
callbacks.onVaultChanged?.()
}
}
function notifyModifiedPath({ relativePath, callbacks }: PathNotification): void {
if (relativePath) {
callbacks.onFileModified?.(relativePath)
} else {
callbacks.onVaultChanged?.()
}
}
function markdownPathFromToolInput(context: ToolInputContext): string | null {
return markdownVaultRelativePath({
filePath: parseFilePath(context),
vaultPath: context.vaultPath,
})
}
function parseFilePath(source: ToolInputSource): string | null {
const parsed = parseToolInput(source)
if (!parsed) return null
return stringField(parsed, ['file_path', 'path'])
}
function parseToolInput(source: ToolInputSource): Record<string, unknown> | null {
if (!source.input) return null
try {
const parsed = JSON.parse(source.input)
return isRecord(parsed) ? parsed : null
} catch {
return null
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function stringField(record: Record<string, unknown>, keys: readonly string[]): string | null {
for (const key of keys) {
const value = record[key]
if (typeof value === 'string') return value
}
return null
}
function markdownVaultRelativePath(request: {
filePath: string | null
vaultPath: string
}): string | null {
if (!request.filePath || !request.filePath.endsWith('.md')) return null
return toVaultRelative({
filePath: request.filePath,
vaultPath: request.vaultPath,
})
}
function toVaultRelative({ filePath, vaultPath }: VaultRelativePathRequest): string | null {
const vaultRoot = vaultPath.replace(/\/+$/, '')
const prefix = `${vaultRoot}/`
if (!filePath.startsWith(prefix)) return null
return filePath.slice(prefix.length) || null
}
export function parseBashFileCreation(request: BashFileCreationRequest): string | null {
return markdownVaultRelativePath({
filePath: markdownRedirectTarget(bashCommandFromInput(request)),
vaultPath: request.vaultPath,
})
}
function bashCommandFromInput(source: ToolInputSource): string | null {
const parsed = parseToolInput(source)
if (!parsed) return null
return stringField(parsed, ['command', 'cmd'])
}
function markdownRedirectTarget(command: string | null): string | null {
return command?.match(/(?:>|>>|tee\s+(?:-a\s+)?)\s*["']?([^\s"'|;]+\.md)["']?/)?.[1] ?? null
}

View File

@@ -1,6 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentStatus } from '../hooks/useAiAgent'
import type { AiAgentMessage } from './aiAgentConversation'
import type { AgentStatus, AiAgentMessage } from './aiAgentConversation'
const {
buildAgentSystemPromptMock,

View File

@@ -4,14 +4,15 @@ import {
appendStreamingMessage,
buildFormattedMessage,
createMissingAgentResponse,
type AgentStatus,
type AgentExecutionContext,
type AiAgentMessage,
type PendingUserPrompt,
} from './aiAgentConversation'
import type { AgentFileCallbacks } from './aiAgentFileOperations'
import { createStreamCallbacks } from './aiAgentStreamCallbacks'
import type { ToolInvocation } from './aiAgentMessageState'
import { streamAiAgent } from '../utils/streamAiAgent'
import type { AgentFileCallbacks, AgentStatus } from '../hooks/useAiAgent'
export interface AiAgentSessionRuntime {
setMessages: Dispatch<SetStateAction<AiAgentMessage[]>>

View File

@@ -1,12 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentStatus } from '../hooks/useAiAgent'
import type { AiAgentMessage } from './aiAgentConversation'
import type { AgentStatus, AiAgentMessage } from './aiAgentConversation'
const { detectFileOperationMock } = vi.hoisted(() => ({
detectFileOperationMock: vi.fn(),
}))
vi.mock('../hooks/useAiAgent', () => ({
vi.mock('./aiAgentFileOperations', async (importOriginal) => ({
...await importOriginal<typeof import('./aiAgentFileOperations')>(),
detectFileOperation: detectFileOperationMock,
}))
@@ -78,12 +78,12 @@ describe('aiAgentStreamCallbacks', () => {
tool: 'Write',
input: '{"path":"/vault/note.md"}',
})
expect(detectFileOperationMock).toHaveBeenCalledWith(
'Write',
'{"path":"/vault/note.md"}',
'/vault',
fileCallbacks,
)
expect(detectFileOperationMock).toHaveBeenCalledWith({
toolName: 'Write',
input: '{"path":"/vault/note.md"}',
vaultPath: '/vault',
callbacks: fileCallbacks,
})
expect(fileCallbacks.onVaultChanged).toHaveBeenCalledTimes(1)
expect(messages.getMessages()).toEqual([
{

View File

@@ -1,7 +1,6 @@
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
import type { AgentFileCallbacks, AgentStatus } from '../hooks/useAiAgent'
import { detectFileOperation } from '../hooks/useAiAgent'
import type { AiAgentMessage } from './aiAgentConversation'
import type { AgentStatus, AiAgentMessage } from './aiAgentConversation'
import { detectFileOperation, type AgentFileCallbacks } from './aiAgentFileOperations'
import {
markReasoningDone,
updateMessage,
@@ -73,7 +72,12 @@ export function createStreamCallbacks(context: StreamMutationContext) {
const info = toolInputMapRef.current.get(toolId)
if (info) {
detectFileOperation(info.tool, info.input, vaultPath, fileCallbacksRef.current)
detectFileOperation({
toolName: info.tool,
input: info.input,
vaultPath,
callbacks: fileCallbacksRef.current,
})
}
updateMessage(setMessages, messageId, (message) => ({

View File

@@ -194,7 +194,6 @@ describe('mockHandlers additional coverage', () => {
expect(mockHandlers.check_mcp_status()).toBe('installed')
expect(mockHandlers.reinit_telemetry()).toBeNull()
expect(mockHandlers.stream_claude_chat()).toBe('mock-session')
expect(mockHandlers.stream_claude_agent()).toBeNull()
expect(mockHandlers.stream_ai_agent()).toBeNull()
})
})

View File

@@ -391,7 +391,6 @@ export const mockHandlers: Record<string, (args: any) => any> = {
return { ...mockVaultAiGuidanceStatus }
},
stream_claude_chat: () => 'mock-session',
stream_claude_agent: () => null,
stream_ai_agent: () => null,
save_note_content: (args: { path: string; content: string }) => {
MOCK_CONTENT[args.path] = args.content

View File

@@ -1,11 +1,6 @@
import { describe, it, expect, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
// Mock the mock-tauri module before importing ai-agent
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
}))
import { buildAgentSystemPrompt, streamClaudeAgent } from './ai-agent'
import { buildAgentSystemPrompt } from './ai-agent'
// --- buildAgentSystemPrompt ---
@@ -32,29 +27,3 @@ describe('buildAgentSystemPrompt', () => {
expect(prompt).toMatch(/wikilink/i)
})
})
// --- streamClaudeAgent ---
describe('streamClaudeAgent', () => {
it('calls onText and onDone in non-Tauri environment', async () => {
const onText = vi.fn()
const onToolStart = vi.fn()
const onDone = vi.fn()
const onError = vi.fn()
await streamClaudeAgent('test message', 'system prompt', '/tmp/vault', {
onText,
onToolStart,
onError,
onDone,
})
// Wait for the setTimeout mock response
await new Promise(r => setTimeout(r, 400))
expect(onText).toHaveBeenCalledWith(expect.stringContaining('Build Laputa App'))
expect(onDone).toHaveBeenCalled()
expect(onError).not.toHaveBeenCalled()
expect(onToolStart).not.toHaveBeenCalled()
})
})

View File

@@ -6,8 +6,6 @@
* The frontend receives streaming events for text, tool calls, and completion.
*/
import { isTauri } from '../mock-tauri'
// --- Agent system prompt ---
const AGENT_SYSTEM_PREAMBLE = `You are working inside Tolaria, a personal knowledge management app.
@@ -25,125 +23,3 @@ export function buildAgentSystemPrompt(vaultContext?: string): string {
if (!vaultContext) return AGENT_SYSTEM_PREAMBLE
return `${AGENT_SYSTEM_PREAMBLE}\n\nVault context:\n${vaultContext}`
}
// --- Claude CLI agent streaming ---
type ClaudeAgentStreamEvent =
| { kind: 'Init'; session_id: string }
| { kind: 'TextDelta'; text: string }
| { kind: 'ThinkingDelta'; text: string }
| { kind: 'ToolStart'; tool_name: string; tool_id: string; input?: string }
| { kind: 'ToolDone'; tool_id: string; output?: string }
| { kind: 'Result'; text: string; session_id: string }
| { kind: 'Error'; message: string }
| { kind: 'Done' }
export interface AgentStreamCallbacks {
onText: (text: string) => void
onThinking: (text: string) => void
onToolStart: (toolName: string, toolId: string, input?: string) => void
onToolDone: (toolId: string, output?: string) => void
onError: (message: string) => void
onDone: () => void
}
/**
* Generate a mock response for browser/test mode.
* Inspects the message for conversation history so Playwright tests
* can verify that history is actually being sent.
*/
function mockAgentResponse(message: string): string {
if (message.includes('<conversation_history>')) {
const allUserLines = message.match(/\[user\]: .+/g) ?? []
const turnCount = allUserLines.length
const lastLine = allUserLines[allUserLines.length - 1] ?? ''
const lastUserMsg = lastLine.replace('[user]: ', '')
return `[mock-with-history turns=${turnCount}] You asked: "${lastUserMsg}" — This note is related to [[Build Laputa App]] and [[Matteo Cellini]].`
}
return `[mock-no-history] You said: "${message}" — This note is related to [[Build Laputa App]] and [[Matteo Cellini]].`
}
function emitMockAgentResponse(message: string, callbacks: AgentStreamCallbacks): void {
setTimeout(() => {
callbacks.onText(mockAgentResponse(message))
callbacks.onDone()
}, 300)
}
function createStreamCloser(callbacks: AgentStreamCallbacks): () => void {
let closed = false
return () => {
if (closed) return
closed = true
callbacks.onDone()
}
}
function handleClaudeStreamEvent(
data: ClaudeAgentStreamEvent,
callbacks: AgentStreamCallbacks,
closeStream: () => void,
): void {
switch (data.kind) {
case 'TextDelta':
callbacks.onText(data.text)
return
case 'ThinkingDelta':
callbacks.onThinking(data.text)
return
case 'ToolStart':
callbacks.onToolStart(data.tool_name, data.tool_id, data.input)
return
case 'ToolDone':
callbacks.onToolDone(data.tool_id, data.output)
return
case 'Error':
callbacks.onError(data.message)
return
case 'Done':
closeStream()
return
default:
return
}
}
/**
* Stream an agent task through the Claude CLI subprocess with scoped tool access.
* The CLI handles the tool-use loop; we receive events for UI updates.
*/
export async function streamClaudeAgent(
message: string,
systemPrompt: string | undefined,
vaultPath: string,
callbacks: AgentStreamCallbacks,
): Promise<void> {
if (!isTauri()) {
emitMockAgentResponse(message, callbacks)
return
}
const { invoke } = await import('@tauri-apps/api/core')
const { listen } = await import('@tauri-apps/api/event')
const closeStream = createStreamCloser(callbacks)
const unlisten = await listen<ClaudeAgentStreamEvent>('claude-agent-stream', (event) => {
handleClaudeStreamEvent(event.payload, callbacks, closeStream)
})
try {
await invoke<string>('stream_claude_agent', {
request: {
message,
system_prompt: systemPrompt || null,
vault_path: vaultPath,
},
})
closeStream()
} catch (err) {
callbacks.onError(err instanceof Error ? err.message : String(err))
closeStream()
} finally {
unlisten()
}
}