refactor: consolidate ai agent session flow
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
95
src/hooks/useCliAiAgent.test.ts
Normal file
95
src/hooks/useCliAiAgent.test.ts
Normal 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.',
|
||||
})])
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
67
src/lib/aiAgentFileOperations.test.ts
Normal file
67
src/lib/aiAgentFileOperations.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
163
src/lib/aiAgentFileOperations.ts
Normal file
163
src/lib/aiAgentFileOperations.ts
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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[]>>
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user