From 61a2e9b8aff5c7e4c9abd35cbcbb40d1391b6e3c Mon Sep 17 00:00:00 2001 From: Adam Jeniski Date: Mon, 19 Jan 2026 23:45:03 -0500 Subject: [PATCH] add git diffs and permission support --- CLAUDE.md | 46 ++- client/package-lock.json | 15 + client/package.json | 3 + client/src/app.css | 14 + client/src/lib/api.ts | 66 +++- client/src/lib/components/FileDiff.svelte | 113 ++++++ client/src/lib/components/InputBar.svelte | 39 +- client/src/lib/components/MessageList.svelte | 338 +++++++++++++++--- .../lib/components/PermissionRequest.svelte | 77 +++- client/src/lib/components/SessionCard.svelte | 13 +- .../src/lib/components/SessionSettings.svelte | 83 +++++ client/src/lib/stores/sessions.ts | 97 ++++- client/src/routes/+page.svelte | 6 +- client/src/routes/session/[id]/+page.svelte | 102 +++++- client/tailwind.config.js | 3 + client/vite.config.ts | 2 +- e2e/CLAUDE.md | 11 +- e2e/global-setup.ts | 20 +- e2e/global-teardown.ts | 16 + e2e/playwright.config.ts | 8 +- e2e/server-utils.ts | 1 + e2e/tests/basic.spec.ts | 5 +- e2e/tests/file-workflow-opencode.spec.ts | 182 ++++++++++ ...ons.spec.ts => permissions-claude.spec.ts} | 59 +-- e2e/tests/sync-claude.spec.ts | 113 ++++++ e2e/tests/workflow-claude.spec.ts | 104 ++++++ ...flow.spec.ts => workflow-opencode.spec.ts} | 36 +- foo.md | 5 + script/dev | 57 ++- script/test | 16 +- server/deps.edn | 7 +- server/dev/user.clj | 76 ++++ server/foo.md | 5 + server/resources/logback.xml | 22 +- server/src/spiceflow/adapters/opencode.clj | 182 +++++++--- server/src/spiceflow/api/routes.clj | 7 +- server/src/spiceflow/api/websocket.clj | 20 +- server/src/spiceflow/core.clj | 2 + server/src/spiceflow/db/memory.clj | 4 + server/src/spiceflow/db/protocol.clj | 4 +- server/src/spiceflow/db/sqlite.clj | 143 ++++++-- server/src/spiceflow/session/manager.clj | 194 ++++++++-- server/test-opencode.md | 1 + test-opencode.md | 1 + 44 files changed, 2051 insertions(+), 267 deletions(-) create mode 100644 client/src/lib/components/FileDiff.svelte create mode 100644 client/src/lib/components/SessionSettings.svelte create mode 100644 e2e/tests/file-workflow-opencode.spec.ts rename e2e/tests/{permissions.spec.ts => permissions-claude.spec.ts} (67%) create mode 100644 e2e/tests/sync-claude.spec.ts create mode 100644 e2e/tests/workflow-claude.spec.ts rename e2e/tests/{workflow.spec.ts => workflow-opencode.spec.ts} (75%) create mode 100644 foo.md create mode 100644 server/dev/user.clj create mode 100644 server/foo.md create mode 100644 server/test-opencode.md create mode 100644 test-opencode.md diff --git a/CLAUDE.md b/CLAUDE.md index 197069f..18ca850 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,7 +71,13 @@ Claude Code/OpenCode CLI ↔ Spiceflow Server (Clojure) ↔ PWA Client (SvelteKi - **State**: Svelte stores in `src/lib/stores/` (sessions, runtime selection) - **API client**: `src/lib/api.ts` - HTTP and WebSocket clients - **Components**: `src/lib/components/` - UI components + - `MessageList.svelte` - Displays messages with collapsible long content + - `PermissionRequest.svelte` - Permission prompts with accept/deny/steer actions + - `FileDiff.svelte` - Expandable file diffs for Write/Edit operations + - `SessionSettings.svelte` - Session settings dropdown (auto-accept edits) + - `InputBar.svelte` - Message input with steer mode support - **PWA**: vite-plugin-pwa with Workbox service worker +- **Responsive**: Landscape mobile mode collapses header to hamburger menu ### Key Protocols @@ -98,6 +104,29 @@ Server configuration via `server/resources/config.edn` or environment variables: | `CLAUDE_SESSIONS_DIR` | ~/.claude/projects | Claude sessions directory | | `OPENCODE_CMD` | opencode | OpenCode command | +## Features + +### Permission Handling +When Claude Code requests permission for file operations (Write/Edit) or shell commands (Bash), Spiceflow intercepts these and presents them to the user: +- **Accept**: Grant permission and continue +- **Deny**: Reject the request +- **Steer ("No, and...")**: Redirect Claude with alternative instructions + +File operations show expandable diffs displaying the exact changes being made. + +### Auto-Accept Edits +Claude sessions can enable "Auto-accept edits" in session settings to automatically grant Write/Edit permissions, reducing interruptions during coding sessions. + +### Session Management +- **Rename**: Click session title to rename +- **Delete**: Remove sessions from the session list +- **Condense**: Collapse long messages for easier scrolling + +### Mobile Optimization +- Landscape mode collapses the header to a hamburger menu +- Compact file diffs with minimal padding +- Touch-friendly permission buttons + ## Session Flow 1. User opens PWA → sees list of tracked sessions @@ -107,7 +136,22 @@ Server configuration via `server/resources/config.edn` or environment variables: 5. Server pipes user message to stdin 6. CLI streams response via stdout (JSONL format) 7. Server broadcasts to client via WebSocket -8. Process completes → response saved to database +8. **If permission required** → WebSocket sends permission request → User accepts/denies/steers +9. Process completes → response saved to database + +## API Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/health` | GET | Health check | +| `/api/sessions` | GET | List all sessions | +| `/api/sessions` | POST | Create new session | +| `/api/sessions/:id` | GET | Get session with messages | +| `/api/sessions/:id` | PATCH | Update session (title, auto-accept-edits) | +| `/api/sessions/:id` | DELETE | Delete session | +| `/api/sessions/:id/send` | POST | Send message to session | +| `/api/sessions/:id/permission` | POST | Respond to permission request | +| `/ws` | WebSocket | Real-time message streaming | ## Tech Stack diff --git a/client/package-lock.json b/client/package-lock.json index f03950c..3055a5b 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "spiceflow-client", "version": "0.1.0", + "dependencies": { + "marked": "^17.0.1" + }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.1", "@sveltejs/kit": "^2.5.0", @@ -5005,6 +5008,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz", + "integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/client/package.json b/client/package.json index 2ac1682..4ed8326 100644 --- a/client/package.json +++ b/client/package.json @@ -26,5 +26,8 @@ "vite": "^5.0.12", "vite-plugin-pwa": "^0.19.2", "workbox-window": "^7.0.0" + }, + "dependencies": { + "marked": "^17.0.1" } } diff --git a/client/src/app.css b/client/src/app.css index 5402d72..e2f901a 100644 --- a/client/src/app.css +++ b/client/src/app.css @@ -61,4 +61,18 @@ .safe-top { padding-top: env(safe-area-inset-top, 0); } + + /* Landscape mobile - short viewport height indicates landscape on mobile */ + .landscape-menu { + display: none; + } + + @media (max-height: 450px) { + .landscape-mobile\:hidden { + display: none !important; + } + .landscape-menu { + display: block; + } + } } diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 337b0cb..11fb218 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -8,12 +8,16 @@ export interface Session { title?: string; 'working-dir'?: string; workingDir?: string; - status: 'idle' | 'running' | 'completed'; + status: 'idle' | 'processing' | 'awaiting-permission'; + 'auto-accept-edits'?: boolean; + autoAcceptEdits?: boolean; 'created-at'?: string; createdAt?: string; 'updated-at'?: string; updatedAt?: string; messages?: Message[]; + 'pending-permission'?: PermissionRequest; + pendingPermission?: PermissionRequest; } export interface Message { @@ -27,9 +31,26 @@ export interface Message { createdAt?: string; } +export interface WriteToolInput { + file_path: string; + content: string; +} + +export interface EditToolInput { + file_path: string; + old_string: string; + new_string: string; +} + +export interface BashToolInput { + command: string; +} + +export type ToolInput = WriteToolInput | EditToolInput | BashToolInput | Record; + export interface PermissionDenial { tool: string; - input: Record; + input: ToolInput; description: string; } @@ -47,6 +68,7 @@ export interface StreamEvent { type?: string; message?: string; cwd?: string; + 'working-dir'?: string; 'permission-request'?: PermissionRequest; permissionRequest?: PermissionRequest; } @@ -141,6 +163,10 @@ export class WebSocketClient { private reconnectDelay = 1000; private listeners: Map void>> = new Map(); private globalListeners: Set<(event: StreamEvent) => void> = new Set(); + private heartbeatInterval: ReturnType | null = null; + private heartbeatTimeoutMs = 25000; // Send ping every 25 seconds + private lastPongTime: number = 0; + private pongTimeoutMs = 10000; // Consider connection dead if no pong within 10 seconds constructor(url: string = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/api/ws`) { this.url = url; @@ -158,11 +184,13 @@ export class WebSocketClient { this.ws.onopen = () => { console.log('WebSocket connected'); this.reconnectAttempts = 0; + this.startHeartbeat(); resolve(); }; this.ws.onclose = () => { console.log('WebSocket disconnected'); + this.stopHeartbeat(); this.attemptReconnect(); }; @@ -174,6 +202,7 @@ export class WebSocketClient { this.ws.onmessage = (event) => { try { const data = JSON.parse(event.data) as StreamEvent; + console.log('[WS Raw] Message received:', data.event || data.type, data); this.handleMessage(data); } catch (e) { console.error('Failed to parse WebSocket message:', e); @@ -183,6 +212,12 @@ export class WebSocketClient { } private handleMessage(event: StreamEvent) { + // Track pong responses for heartbeat + if (event.type === 'pong') { + this.lastPongTime = Date.now(); + return; + } + // Notify global listeners this.globalListeners.forEach((listener) => listener(event)); @@ -194,6 +229,32 @@ export class WebSocketClient { } } + private startHeartbeat() { + this.stopHeartbeat(); + this.lastPongTime = Date.now(); + + this.heartbeatInterval = setInterval(() => { + if (this.ws?.readyState === WebSocket.OPEN) { + // Check if we received a pong since last ping + const timeSinceLastPong = Date.now() - this.lastPongTime; + if (timeSinceLastPong > this.heartbeatTimeoutMs + this.pongTimeoutMs) { + console.warn('WebSocket heartbeat timeout, reconnecting...'); + this.ws?.close(); + return; + } + + this.send({ type: 'ping' }); + } + }, this.heartbeatTimeoutMs); + } + + private stopHeartbeat() { + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval); + this.heartbeatInterval = null; + } + } + private attemptReconnect() { if (this.reconnectAttempts >= this.maxReconnectAttempts) { console.error('Max reconnection attempts reached'); @@ -236,6 +297,7 @@ export class WebSocketClient { } disconnect() { + this.stopHeartbeat(); this.ws?.close(); this.ws = null; } diff --git a/client/src/lib/components/FileDiff.svelte b/client/src/lib/components/FileDiff.svelte new file mode 100644 index 0000000..8fa8880 --- /dev/null +++ b/client/src/lib/components/FileDiff.svelte @@ -0,0 +1,113 @@ + + +
+ +
+ {isWrite ? 'New file' : 'Edit'}: + {filePath} + {#if extension} + {extension} + {/if} +
+ + +
+ {#if isWrite} + + + + {#each splitLines(writeInput.content) as line, i} + + + + + + {/each} + +
{i + 1}+{line || ' '}
+ {:else if isEdit} + + + + + {#each splitLines(editInput.old_string) as line, i} + + + + + + {/each} + + {#if splitLines(editInput.old_string).length > 0 && splitLines(editInput.new_string).length > 0} + + + + {/if} + + {#each splitLines(editInput.new_string) as line, i} + + + + + + {/each} + +
{i + 1}-{line || ' '}
{i + 1}+{line || ' '}
+ {:else} + +
+
{JSON.stringify(input, null, 2)}
+
+ {/if} +
+
+ + diff --git a/client/src/lib/components/InputBar.svelte b/client/src/lib/components/InputBar.svelte index 14900b0..0e05b62 100644 --- a/client/src/lib/components/InputBar.svelte +++ b/client/src/lib/components/InputBar.svelte @@ -8,6 +8,12 @@ let message = ''; let textarea: HTMLTextAreaElement; + let expanded = false; + + const LINE_HEIGHT = 22; // approximate line height in pixels + const PADDING = 24; // vertical padding + const MIN_HEIGHT_COLLAPSED = LINE_HEIGHT + PADDING; // 1 line + const MIN_HEIGHT_EXPANDED = LINE_HEIGHT * 4 + PADDING; // 4 lines function handleSubmit() { const trimmed = message.trim(); @@ -15,6 +21,7 @@ dispatch('send', trimmed); message = ''; + expanded = false; resizeTextarea(); } @@ -27,8 +34,16 @@ function resizeTextarea() { if (!textarea) return; - textarea.style.height = 'auto'; - textarea.style.height = Math.min(textarea.scrollHeight, 150) + 'px'; + if (expanded) { + textarea.style.height = MIN_HEIGHT_EXPANDED + 'px'; + } else { + textarea.style.height = MIN_HEIGHT_COLLAPSED + 'px'; + } + } + + function toggleExpanded() { + expanded = !expanded; + resizeTextarea(); } export function focus() { @@ -38,6 +53,22 @@
+ + + {#if expandedHistoryDenials.has(`${message.id}-${index}`)} +
+ +
+ {/if} + {:else} + +
+ {denial.tool}: + {denial.description} +
+ {/if} + + {/each} + +
+ + + {:else} +
isCollapsible && toggleCollapse(message.id)} + on:keydown={(e) => e.key === 'Enter' && isCollapsible && toggleCollapse(message.id)} + role={isCollapsible ? 'button' : undefined} + tabindex={isCollapsible ? 0 : undefined} + > +
- {roleLabels[message.role]} - + {@html renderMarkdown(message.content)} +
+ {#if isCollapsible} + + › + + {/if}
-
- {message.content} -
- + {/if} {/each} {#if isThinking && !streamingContent}
-
- - Assistant - - - - - - +
+ + +
{:else if streamingContent}
-
- - Assistant - - - - - - -
-
- {streamingContent}| +
+ {@html renderMarkdown(streamingContent)}|
{/if} {/if}
+ + diff --git a/client/src/lib/components/PermissionRequest.svelte b/client/src/lib/components/PermissionRequest.svelte index 8974ec2..d0b96ac 100644 --- a/client/src/lib/components/PermissionRequest.svelte +++ b/client/src/lib/components/PermissionRequest.svelte @@ -1,8 +1,10 @@ + + + +
+ + + {#if open} +
+
+ Session Settings +
+ + {#if provider === 'claude'} + + {:else} +
+ No settings available for OpenCode sessions yet. +
+ {/if} +
+ {/if} +
diff --git a/client/src/lib/stores/sessions.ts b/client/src/lib/stores/sessions.ts index 99bf924..e811682 100644 --- a/client/src/lib/stores/sessions.ts +++ b/client/src/lib/stores/sessions.ts @@ -106,10 +106,13 @@ function createActiveSessionStore() { try { const session = await api.getSession(id); + // Load pending permission from session if it exists (persisted in DB) + const pendingPermission = session['pending-permission'] || session.pendingPermission || null; update((s) => ({ ...s, session, messages: session.messages || [], + pendingPermission, loading: false })); @@ -174,8 +177,34 @@ function createActiveSessionStore() { const state = get(); if (!state.session || !state.pendingPermission) return; - // Clear pending permission immediately - update((s) => ({ ...s, pendingPermission: null })); + const permission = state.pendingPermission as PermissionRequest & { 'message-id'?: string }; + const messageId = permission['message-id']; + + // Show loading indicator while LLM processes the permission response + // (unless user is steering, which requires them to provide a message) + const showThinking = response !== 'steer' || message; + + // Update the permission message's status locally + // The server will also persist this, but we update locally for immediate feedback + if (messageId) { + update((s) => ({ + ...s, + messages: s.messages.map((msg) => { + if (msg.id === messageId) { + return { + ...msg, + metadata: { ...msg.metadata, status: response } + }; + } + return msg; + }), + pendingPermission: null, + isThinking: showThinking ? true : s.isThinking + })); + } else { + // No message-id (legacy), just clear pending permission + update((s) => ({ ...s, pendingPermission: null, isThinking: showThinking ? true : s.isThinking })); + } try { await api.respondToPermission(state.session.id, response, message); @@ -201,6 +230,26 @@ function createActiveSessionStore() { throw e; } }, + async setAutoAcceptEdits(enabled: boolean) { + const state = get(); + if (!state.session) return; + + try { + const updated = await api.updateSession(state.session.id, { + 'auto-accept-edits': enabled + }); + update((s) => ({ + ...s, + session: s.session ? { ...s.session, ...updated } : null + })); + // Also update in the sessions list + sessions.updateSession(state.session.id, { 'auto-accept-edits': enabled }); + return updated; + } catch (e) { + update((s) => ({ ...s, error: (e as Error).message })); + throw e; + } + }, clear() { if (unsubscribeWs) { unsubscribeWs(); @@ -225,6 +274,7 @@ function createActiveSessionStore() { } function handleStreamEvent(event: StreamEvent) { + console.log('[WS] Received event:', event.event, event); if (event.event === 'init' && event.cwd) { // Update session's working directory from init event update((s) => { @@ -234,6 +284,26 @@ function createActiveSessionStore() { session: { ...s.session, 'working-dir': event.cwd, workingDir: event.cwd } }; }); + // Also update in the sessions list + const state = get(); + if (state.session) { + sessions.updateSession(state.session.id, { 'working-dir': event.cwd }); + } + } else if (event.event === 'working-dir-update' && event['working-dir']) { + // Update session's working directory when detected from tool results + const newDir = event['working-dir']; + update((s) => { + if (!s.session) return s; + return { + ...s, + session: { ...s.session, 'working-dir': newDir, workingDir: newDir } + }; + }); + // Also update in the sessions list + const state = get(); + if (state.session) { + sessions.updateSession(state.session.id, { 'working-dir': newDir }); + } } else if (event.event === 'content-delta' && event.text) { update((s) => ({ ...s, streamingContent: s.streamingContent + event.text, isThinking: false })); } else if (event.event === 'message-stop') { @@ -257,8 +327,25 @@ function createActiveSessionStore() { }); } else if (event.event === 'permission-request') { const permReq = event['permission-request'] || event.permissionRequest; + const permMessage = (event as StreamEvent & { message?: Message }).message; + const messageId = (event as StreamEvent & { 'message-id'?: string })['message-id']; + console.log('[WS] Permission request received:', permReq, 'message:', permMessage); if (permReq) { - update((s) => ({ ...s, pendingPermission: permReq })); + // Store the message-id in the permission request for later status update + const permReqWithMsgId = messageId ? { ...permReq, 'message-id': messageId } : permReq; + update((s) => { + // If we received the full message, add it to messages array + // Otherwise just update pendingPermission + if (permMessage) { + return { + ...s, + pendingPermission: permReqWithMsgId, + messages: [...s.messages, permMessage] + }; + } + return { ...s, pendingPermission: permReqWithMsgId }; + }); + console.log('[WS] pendingPermission state updated with message-id:', messageId); } } else if (event.event === 'error') { update((s) => ({ ...s, error: event.message || 'Stream error', isThinking: false })); @@ -278,6 +365,6 @@ export const sortedSessions: Readable = derived(sessions, ($sessions) }) ); -export const runningSessions: Readable = derived(sessions, ($sessions) => - $sessions.sessions.filter((s) => s.status === 'running') +export const processingSessions: Readable = derived(sessions, ($sessions) => + $sessions.sessions.filter((s) => s.status === 'processing') ); diff --git a/client/src/routes/+page.svelte b/client/src/routes/+page.svelte index fe3d4e2..b215c3c 100644 --- a/client/src/routes/+page.svelte +++ b/client/src/routes/+page.svelte @@ -1,6 +1,6 @@ @@ -102,8 +111,10 @@ {session?.title || `Session ${shortId}`} - Spiceflow - -
+ (menuOpen = false)} /> + + +
+ + {#if menuOpen} + +
+ {#if session} +
+
+ + {session.title || `Session ${shortId}`} +
+ {#if projectName} +

{projectName}

+ {/if} +
+ {/if} + + + {#if session?.provider === 'claude'} + + {/if} +
+ {/if} +
+ {#if $activeSession.error}
@@ -181,18 +257,26 @@
{:else} {#if workingDir} -
+
{workingDir} + +
{/if} - + {#if $activeSession.pendingPermission} diff --git a/client/tailwind.config.js b/client/tailwind.config.js index f13e987..8e84858 100644 --- a/client/tailwind.config.js +++ b/client/tailwind.config.js @@ -20,6 +20,9 @@ export default { }, fontFamily: { mono: ['JetBrains Mono', 'Fira Code', 'monospace'] + }, + screens: { + 'landscape-mobile': { raw: '(orientation: landscape) and (max-height: 500px)' } } } }, diff --git a/client/vite.config.ts b/client/vite.config.ts index 4c1a9de..339eea7 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -64,7 +64,7 @@ export default defineConfig({ host: '0.0.0.0', proxy: { '/api': { - target: 'http://localhost:3000', + target: `http://localhost:${process.env.VITE_BACKEND_PORT || 3000}`, changeOrigin: true, ws: true } diff --git a/e2e/CLAUDE.md b/e2e/CLAUDE.md index d597297..2580689 100644 --- a/e2e/CLAUDE.md +++ b/e2e/CLAUDE.md @@ -23,10 +23,12 @@ npm run test:ui The e2e setup automatically manages both servers: -1. **Global Setup** (`global-setup.ts`) - Starts backend (port 3000) and frontend (port 5173) +1. **Global Setup** (`global-setup.ts`) - Starts backend (port 3001) and frontend (port 5174) 2. **Global Teardown** (`global-teardown.ts`) - Stops both servers 3. **Server Utils** (`server-utils.ts`) - Spawns Clojure and Vite processes, waits for health checks +E2E tests use different ports (3001/5174) than dev servers (3000/5173) to allow running tests without interfering with development. + Tests use Playwright's `page` fixture for browser interactions and `request` fixture for direct API calls. ## Test Database @@ -37,12 +39,13 @@ E2E tests use a separate database (`server/test-e2e.db`) to avoid polluting the ```typescript import { test, expect } from '@playwright/test'; +import { E2E_BACKEND_URL } from '../playwright.config'; test('example', async ({ page, request }) => { - // Direct API call - const response = await request.get('http://localhost:3000/api/sessions'); + // Direct API call - use E2E_BACKEND_URL for backend requests + const response = await request.get(`${E2E_BACKEND_URL}/api/sessions`); - // Browser interaction + // Browser interaction - baseURL is configured in playwright.config.ts await page.goto('/'); await expect(page.locator('h1')).toBeVisible(); }); diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index e3f41e5..8d68549 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -1,12 +1,30 @@ import { startServers } from './server-utils.js'; +import { E2E_BACKEND_PORT, E2E_FRONTEND_PORT } from './playwright.config.js'; +import { unlinkSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; + +function cleanupTestFiles() { + const testFile = join(homedir(), 'foo.md'); + try { + unlinkSync(testFile); + console.log('Cleaned up test file:', testFile); + } catch { + // File doesn't exist, ignore + } +} export default async function globalSetup() { + // Clean up test files from previous runs + cleanupTestFiles(); + // Skip if servers are managed externally (e.g., by scripts/test) if (process.env.SKIP_SERVER_SETUP) { console.log('\n=== Skipping server setup (SKIP_SERVER_SETUP is set) ===\n'); return; } console.log('\n=== Starting E2E Test Environment ===\n'); - await startServers(3000, 5173); + console.log(`Backend port: ${E2E_BACKEND_PORT}, Frontend port: ${E2E_FRONTEND_PORT}`); + await startServers(E2E_BACKEND_PORT, E2E_FRONTEND_PORT); console.log('\n=== E2E Test Environment Ready ===\n'); } diff --git a/e2e/global-teardown.ts b/e2e/global-teardown.ts index ed8be77..0fd2748 100644 --- a/e2e/global-teardown.ts +++ b/e2e/global-teardown.ts @@ -1,6 +1,22 @@ import { stopServers } from './server-utils.js'; +import { unlinkSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; + +function cleanupTestFiles() { + const testFile = join(homedir(), 'foo.md'); + try { + unlinkSync(testFile); + console.log('Cleaned up test file:', testFile); + } catch { + // File doesn't exist, ignore + } +} export default async function globalTeardown() { + // Clean up test files + cleanupTestFiles(); + // Skip if servers are managed externally (e.g., by scripts/test) if (process.env.SKIP_SERVER_SETUP) { console.log('\n=== Skipping server teardown (SKIP_SERVER_SETUP is set) ===\n'); diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 0d24da1..130706c 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -1,5 +1,11 @@ import { defineConfig, devices } from '@playwright/test'; +// E2E uses different ports to avoid conflicts with dev servers +export const E2E_BACKEND_PORT = 3001; +export const E2E_FRONTEND_PORT = 5174; +export const E2E_BACKEND_URL = `http://localhost:${E2E_BACKEND_PORT}`; +export const E2E_FRONTEND_URL = `https://localhost:${E2E_FRONTEND_PORT}`; + export default defineConfig({ testDir: './tests', fullyParallel: false, @@ -9,7 +15,7 @@ export default defineConfig({ reporter: 'list', timeout: 30000, use: { - baseURL: 'https://localhost:5173', + baseURL: E2E_FRONTEND_URL, trace: 'on-first-retry', ignoreHTTPSErrors: true, }, diff --git a/e2e/server-utils.ts b/e2e/server-utils.ts index 68c99ad..e15f833 100644 --- a/e2e/server-utils.ts +++ b/e2e/server-utils.ts @@ -109,6 +109,7 @@ export async function startFrontend(port = 5173, backendPort = 3000): Promise { test('backend health check', async ({ request }) => { - const response = await request.get('http://localhost:3000/api/health'); + const response = await request.get(`${E2E_BACKEND_URL}/api/health`); expect(response.ok()).toBeTruthy(); const body = await response.json(); expect(body.status).toBe('ok'); @@ -15,7 +16,7 @@ test.describe('Basic E2E Tests', () => { }); test('sessions list loads empty', async ({ request }) => { - const response = await request.get('http://localhost:3000/api/sessions'); + const response = await request.get(`${E2E_BACKEND_URL}/api/sessions`); expect(response.ok()).toBeTruthy(); const sessions = await response.json(); expect(Array.isArray(sessions)).toBeTruthy(); diff --git a/e2e/tests/file-workflow-opencode.spec.ts b/e2e/tests/file-workflow-opencode.spec.ts new file mode 100644 index 0000000..9884951 --- /dev/null +++ b/e2e/tests/file-workflow-opencode.spec.ts @@ -0,0 +1,182 @@ +import { test, expect } from '@playwright/test'; + +test.describe('OpenCode File Workflow', () => { + test('create, read, and delete file without permission prompts', async ({ page }) => { + // Increase timeout for this test since it involves multiple AI interactions + test.setTimeout(180000); + + // Enable console logging for debugging + page.on('console', (msg) => { + console.log(`[Browser ${msg.type()}]`, msg.text()); + }); + + // Log WebSocket frames for debugging + page.on('websocket', (ws) => { + console.log(`[WebSocket] Connected to ${ws.url()}`); + ws.on('framesent', (frame) => console.log(`[WS Sent]`, frame.payload)); + ws.on('framereceived', (frame) => console.log(`[WS Received]`, frame.payload)); + }); + + // 1. Navigate to homepage + await page.goto('/'); + await expect(page).toHaveTitle(/Spiceflow/i); + + // 2. Click the + button to open new session menu + const createButton = page.locator('button[title="New Session"]'); + await expect(createButton).toBeVisible(); + await createButton.click(); + + // 3. Select OpenCode from the dropdown + const opencodeOption = page.locator('button:has-text("OpenCode")'); + await expect(opencodeOption).toBeVisible(); + await opencodeOption.click(); + + // 4. Wait for navigation to session page + await page.waitForURL(/\/session\/.+/); + console.log('[Test] Navigated to session page:', page.url()); + + // 5. Wait for the page to load + await expect(page.locator('text=Loading')).not.toBeVisible({ timeout: 5000 }); + await expect(page.locator('text=No messages yet')).toBeVisible(); + + const textarea = page.locator('textarea'); + const sendButton = page.locator('button[type="submit"]'); + const bouncingDots = page.locator('.animate-bounce'); + // Only look for pulsing cursor inside markdown-content (not the header status indicator) + const pulsingCursor = page.locator('.markdown-content .animate-pulse'); + + // Messages with .markdown-content are rendered assistant/user messages + const messagesWithContent = page.locator('.rounded-lg.border').filter({ + has: page.locator('.markdown-content') + }); + + // Helper to wait for streaming to complete + const waitForStreamingComplete = async () => { + await expect(bouncingDots).toHaveCount(0, { timeout: 60000 }); + await expect(pulsingCursor).toHaveCount(0, { timeout: 60000 }); + }; + + // ============================================================ + // STEP 1: Create a file + // ============================================================ + console.log('[Test] Step 1: Creating file'); + + await expect(textarea).toBeVisible(); + await textarea.fill( + 'Create a file called test-opencode.md with the content "Hello from OpenCode test". Just create the file, no other commentary.' + ); + await expect(sendButton).toBeEnabled(); + await sendButton.click(); + + // Verify user message appears + await expect(page.locator('text=Create a file called test-opencode.md')).toBeVisible(); + console.log('[Test] User message displayed'); + + // Verify thinking indicator appears (bouncing dots) + await expect(bouncingDots.first()).toBeVisible({ timeout: 2000 }); + console.log('[Test] Thinking indicator appeared'); + + // OpenCode should NOT show permission request - it auto-approves + // Wait a moment to ensure no permission UI appears + await page.waitForTimeout(2000); + const permissionUI = page.locator('text=needs permission'); + await expect(permissionUI).not.toBeVisible(); + console.log('[Test] Confirmed no permission prompt for file creation'); + + // Wait for streaming to complete + await waitForStreamingComplete(); + console.log('[Test] Step 1 complete: File created'); + + // ============================================================ + // STEP 2: Read the file + // ============================================================ + console.log('[Test] Step 2: Reading file'); + + const messageCountAfterCreate = await messagesWithContent.count(); + + await textarea.fill( + 'Read the contents of test-opencode.md and tell me exactly what it says.' + ); + await sendButton.click(); + + // Wait for new assistant message + await expect(messagesWithContent).toHaveCount(messageCountAfterCreate + 1, { timeout: 60000 }); + console.log('[Test] New assistant message appeared for read'); + + // Wait for streaming to complete + await waitForStreamingComplete(); + + // Verify the response contains the file content + const readResponseMessage = messagesWithContent.last(); + const readResponseText = await readResponseMessage.locator('.markdown-content').textContent(); + console.log('[Test] OpenCode read back:', readResponseText); + + expect(readResponseText).toBeTruthy(); + expect(readResponseText).toContain('Hello from OpenCode test'); + console.log('[Test] Step 2 complete: File content verified'); + + // ============================================================ + // STEP 3: Delete the file + // ============================================================ + console.log('[Test] Step 3: Deleting file'); + + const messageCountAfterRead = await messagesWithContent.count(); + + await textarea.fill( + 'Delete the file test-opencode.md. Confirm when done.' + ); + await sendButton.click(); + + // Wait for new assistant message + await expect(messagesWithContent).toHaveCount(messageCountAfterRead + 1, { timeout: 60000 }); + console.log('[Test] New assistant message appeared for delete'); + + // OpenCode should NOT show permission request for delete either + await page.waitForTimeout(1000); + await expect(permissionUI).not.toBeVisible(); + console.log('[Test] Confirmed no permission prompt for file deletion'); + + // Wait for streaming to complete + await waitForStreamingComplete(); + + // Verify delete confirmation + const deleteResponseMessage = messagesWithContent.last(); + const deleteResponseText = await deleteResponseMessage.locator('.markdown-content').textContent(); + console.log('[Test] OpenCode delete response:', deleteResponseText); + + expect(deleteResponseText).toBeTruthy(); + // Response should indicate the file was deleted (various phrasings possible) + expect(deleteResponseText!.toLowerCase()).toMatch(/delet|remov|done|success/); + console.log('[Test] Step 3 complete: File deleted'); + + // ============================================================ + // STEP 4: Verify file is gone + // ============================================================ + console.log('[Test] Step 4: Verifying file no longer exists'); + + const messageCountAfterDelete = await messagesWithContent.count(); + + await textarea.fill( + 'Try to read test-opencode.md again. Does it exist?' + ); + await sendButton.click(); + + // Wait for new assistant message + await expect(messagesWithContent).toHaveCount(messageCountAfterDelete + 1, { timeout: 60000 }); + + // Wait for streaming to complete + await waitForStreamingComplete(); + + // Verify the response indicates file doesn't exist + const verifyResponseMessage = messagesWithContent.last(); + const verifyResponseText = await verifyResponseMessage.locator('.markdown-content').textContent(); + console.log('[Test] OpenCode verify response:', verifyResponseText); + + expect(verifyResponseText).toBeTruthy(); + // Response should indicate file doesn't exist + expect(verifyResponseText!.toLowerCase()).toMatch(/not exist|not found|no such|doesn't exist|does not exist|cannot find|can't find/); + console.log('[Test] Step 4 complete: Confirmed file no longer exists'); + + console.log('[Test] All steps completed successfully!'); + }); +}); diff --git a/e2e/tests/permissions.spec.ts b/e2e/tests/permissions-claude.spec.ts similarity index 67% rename from e2e/tests/permissions.spec.ts rename to e2e/tests/permissions-claude.spec.ts index 6e3013e..db70e5c 100644 --- a/e2e/tests/permissions.spec.ts +++ b/e2e/tests/permissions-claude.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from '@playwright/test'; -test.describe('Permissions Workflow', () => { +test.describe('Claude Permissions Workflow', () => { test('permission approval allows file creation and reading', async ({ page }) => { // Increase timeout for this test since it involves real Claude interaction test.setTimeout(180000); @@ -21,20 +21,25 @@ test.describe('Permissions Workflow', () => { await page.goto('/'); await expect(page).toHaveTitle(/Spiceflow/i); - // 2. Create a new session + // 2. Click the + button to open new session menu const createButton = page.locator('button[title="New Session"]'); await expect(createButton).toBeVisible(); await createButton.click(); - // 3. Wait for navigation to session page + // 3. Select Claude Code from the dropdown + const claudeOption = page.locator('button:has-text("Claude Code")'); + await expect(claudeOption).toBeVisible(); + await claudeOption.click(); + + // 4. Wait for navigation to session page await page.waitForURL(/\/session\/.+/); console.log('[Test] Navigated to session page:', page.url()); - // 4. Wait for the page to load + // 5. Wait for the page to load await expect(page.locator('text=Loading')).not.toBeVisible({ timeout: 5000 }); await expect(page.locator('text=No messages yet')).toBeVisible(); - // 5. Send message asking Claude to create foo.md with a haiku + // 6. Send message asking Claude to create foo.md with a haiku const textarea = page.locator('textarea'); await expect(textarea).toBeVisible(); await textarea.fill( @@ -45,73 +50,75 @@ test.describe('Permissions Workflow', () => { await expect(sendButton).toBeEnabled(); await sendButton.click(); - // 6. Verify user message appears + // 7. Verify user message appears await expect( page.locator('text=Create a file called foo.md') ).toBeVisible(); console.log('[Test] User message displayed'); - // 6b. Verify thinking indicator appears immediately - const assistantBubble = page.locator('.rounded-lg.border').filter({ - has: page.locator('text=Assistant') - }).first(); - await expect(assistantBubble).toBeVisible({ timeout: 2000 }); + // 7b. Verify thinking indicator appears immediately + // The thinking indicator shows bouncing dots + const bouncingDotsIndicator = page.locator('.animate-bounce'); + await expect(bouncingDotsIndicator.first()).toBeVisible({ timeout: 2000 }); console.log('[Test] Thinking indicator appeared immediately'); - // 7. Wait for permission request UI to appear + // 8. Wait for permission request UI to appear const permissionUI = page.locator('text=Claude needs permission'); await expect(permissionUI).toBeVisible({ timeout: 60000 }); console.log('[Test] Permission request UI appeared'); - // 8. Verify the permission shows Write tool for foo.md - const permissionDescription = page.locator('li.font-mono').filter({ + // 9. Verify the permission shows Write tool for foo.md + // The permission UI has li > button.font-mono or li > div.font-mono with "Write:" and filename + const permissionDescription = page.locator('.font-mono').filter({ hasText: /Write.*foo\.md|create.*foo\.md/i }).first(); await expect(permissionDescription).toBeVisible(); console.log('[Test] Permission shows foo.md file creation'); - // 9. Click Accept button + // 10. Click Accept button const acceptButton = page.locator('button:has-text("Accept")'); await expect(acceptButton).toBeVisible(); await acceptButton.click(); console.log('[Test] Clicked Accept button'); - // 10. Wait for permission UI to disappear + // 11. Wait for permission UI to disappear await expect(permissionUI).not.toBeVisible({ timeout: 10000 }); console.log('[Test] Permission UI disappeared'); - // 11. Wait for streaming to complete after permission granted + // 12. Wait for streaming to complete after permission granted await page.waitForTimeout(2000); const bouncingDots = page.locator('.animate-bounce'); - const pulsingCursor = page.locator('.animate-pulse'); + // Only look for pulsing cursor inside markdown-content (not the header status indicator) + const pulsingCursor = page.locator('.markdown-content .animate-pulse'); await expect(bouncingDots).toHaveCount(0, { timeout: 60000 }); await expect(pulsingCursor).toHaveCount(0, { timeout: 60000 }); console.log('[Test] First streaming complete'); - // Count current assistant messages before sending new request + // Count current messages with markdown-content before sending new request + // Assistant messages have .markdown-content inside the bubble const assistantMessages = page.locator('.rounded-lg.border').filter({ - has: page.locator('text=Assistant') + has: page.locator('.markdown-content') }); const messageCountBefore = await assistantMessages.count(); - console.log('[Test] Assistant message count before read request:', messageCountBefore); + console.log('[Test] Message count before read request:', messageCountBefore); - // 12. Now ask Claude to read the file back to verify it was created + // 13. Now ask Claude to read the file back to verify it was created await textarea.fill('Read the contents of foo.md and tell me what it says. Quote the file contents.'); await sendButton.click(); console.log('[Test] Asked Claude to read the file'); - // 13. Wait for a NEW assistant message to appear + // 14. Wait for a NEW message to appear await expect(assistantMessages).toHaveCount(messageCountBefore + 1, { timeout: 60000 }); - console.log('[Test] New assistant message appeared'); + console.log('[Test] New message appeared'); // Wait for streaming to complete on the new message await expect(bouncingDots).toHaveCount(0, { timeout: 60000 }); await expect(pulsingCursor).toHaveCount(0, { timeout: 60000 }); console.log('[Test] Second streaming complete'); - // 14. Verify the response contains "My Haiku" - confirming file was created and read + // 15. Verify the response contains "My Haiku" - confirming file was created and read const lastAssistantMessage = assistantMessages.last(); - const responseText = await lastAssistantMessage.locator('.font-mono').textContent(); + const responseText = await lastAssistantMessage.locator('.markdown-content').textContent(); console.log('[Test] Claude read back:', responseText); // The response should contain "My Haiku" which we asked Claude to title the file diff --git a/e2e/tests/sync-claude.spec.ts b/e2e/tests/sync-claude.spec.ts new file mode 100644 index 0000000..08811ac --- /dev/null +++ b/e2e/tests/sync-claude.spec.ts @@ -0,0 +1,113 @@ +import { test, expect } from '@playwright/test'; +import { E2E_BACKEND_URL } from '../playwright.config'; + +test.describe('Claude Working Directory Auto-Update', () => { + test('working directory updates automatically after cd command', async ({ page, request }) => { + // Increase timeout for this test since it involves real Claude interaction + test.setTimeout(180000); + + // Enable console logging to debug issues + page.on('console', (msg) => { + console.log(`[Browser ${msg.type()}]`, msg.text()); + }); + + // Log WebSocket frames + page.on('websocket', (ws) => { + console.log(`[WebSocket] Connected to ${ws.url()}`); + ws.on('framesent', (frame) => console.log(`[WS Sent]`, frame.payload)); + ws.on('framereceived', (frame) => console.log(`[WS Received]`, frame.payload)); + ws.on('close', () => console.log('[WebSocket] Closed')); + }); + + // Log network requests to /api + page.on('request', (req) => { + if (req.url().includes('/api')) { + console.log(`[Request] ${req.method()} ${req.url()}`); + } + }); + page.on('response', (response) => { + if (response.url().includes('/api')) { + console.log(`[Response] ${response.status()} ${response.url()}`); + } + }); + + // 1. Navigate to homepage + await page.goto('/'); + await expect(page).toHaveTitle(/Spiceflow/i); + + // 2. Click the + button to open new session menu + const createButton = page.locator('button[title="New Session"]'); + await expect(createButton).toBeVisible(); + await createButton.click(); + + // 3. Select Claude Code from the dropdown + const claudeOption = page.locator('button:has-text("Claude Code")'); + await expect(claudeOption).toBeVisible(); + await claudeOption.click(); + + // 4. Wait for navigation to session page + await page.waitForURL(/\/session\/.+/); + const sessionUrl = page.url(); + const sessionId = sessionUrl.split('/session/')[1]; + console.log('[Test] Navigated to session page:', sessionUrl); + console.log('[Test] Session ID:', sessionId); + + // 5. Wait for the page to load (no loading spinner) + await expect(page.locator('text=Loading')).not.toBeVisible({ timeout: 5000 }); + + // 6. Verify we see the empty message state + await expect(page.locator('text=No messages yet')).toBeVisible(); + + // 7. Send a message to Claude asking it to cd into repos (natural language) + // Claude should run the cd command and ideally output the current directory + const textarea = page.locator('textarea'); + await expect(textarea).toBeVisible(); + await textarea.fill('change directory to ~/repos and tell me where you are now'); + + // 8. Click the send button + const sendButton = page.locator('button[type="submit"]'); + await expect(sendButton).toBeEnabled(); + await sendButton.click(); + + // 9. Wait for streaming to complete + const bouncingDots = page.locator('.animate-bounce'); + // Only look for pulsing cursor inside markdown-content (not the header status indicator) + const pulsingCursor = page.locator('.markdown-content .animate-pulse'); + await expect(bouncingDots).toHaveCount(0, { timeout: 60000 }); + await expect(pulsingCursor).toHaveCount(0, { timeout: 60000 }); + console.log('[Test] Message complete'); + + // 10. The working directory bar should now show the repos path (automatically updated) + // The working dir bar is in a specific container with bg-zinc-900/50 + const workingDirBar = page.locator('div.bg-zinc-900\\/50'); + await expect(workingDirBar).toBeVisible({ timeout: 10000 }); + + // The working dir text is in a span.truncate.font-mono inside the bar + const workingDirText = workingDirBar.locator('span.truncate.font-mono'); + await expect(workingDirText).toBeVisible(); + + // 11. Wait for the working directory to contain 'repos' (automatic update from tool result) + await expect(workingDirText).toContainText('repos', { timeout: 10000 }); + const displayedWorkingDir = await workingDirText.textContent(); + console.log('[Test] Working directory in UI:', displayedWorkingDir); + expect(displayedWorkingDir).toContain('repos'); + + // 12. Verify the working directory in the database via API + const sessionResponse = await request.get(`${E2E_BACKEND_URL}/api/sessions/${sessionId}`); + expect(sessionResponse.ok()).toBeTruthy(); + const sessionData = await sessionResponse.json(); + console.log('[Test] Session data from API:', JSON.stringify(sessionData, null, 2)); + + // The API returns session data directly (not nested under 'session') + const dbWorkingDir = sessionData['working-dir'] || sessionData.workingDir || ''; + console.log('[Test] Working directory from DB:', dbWorkingDir); + + // DB should have the repos path + expect(dbWorkingDir).toContain('repos'); + + // UI and DB should match + expect(displayedWorkingDir).toBe(dbWorkingDir); + + console.log('[Test] Auto-sync test passed - working directory automatically updated to repos path'); + }); +}); diff --git a/e2e/tests/workflow-claude.spec.ts b/e2e/tests/workflow-claude.spec.ts new file mode 100644 index 0000000..919b486 --- /dev/null +++ b/e2e/tests/workflow-claude.spec.ts @@ -0,0 +1,104 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Claude Chat Workflow', () => { + test('create new chat and send message to Claude', async ({ page }) => { + // Enable console logging to debug WebSocket issues + page.on('console', (msg) => { + console.log(`[Browser ${msg.type()}]`, msg.text()); + }); + + // Log WebSocket frames + page.on('websocket', (ws) => { + console.log(`[WebSocket] Connected to ${ws.url()}`); + ws.on('framesent', (frame) => console.log(`[WS Sent]`, frame.payload)); + ws.on('framereceived', (frame) => console.log(`[WS Received]`, frame.payload)); + ws.on('close', () => console.log('[WebSocket] Closed')); + }); + + // Log network requests to /api + page.on('request', (request) => { + if (request.url().includes('/api')) { + console.log(`[Request] ${request.method()} ${request.url()}`); + } + }); + page.on('response', (response) => { + if (response.url().includes('/api')) { + console.log(`[Response] ${response.status()} ${response.url()}`); + } + }); + + // 1. Navigate to homepage + await page.goto('/'); + await expect(page).toHaveTitle(/Spiceflow/i); + + // 2. Click the + button to open new session menu + const createButton = page.locator('button[title="New Session"]'); + await expect(createButton).toBeVisible(); + await createButton.click(); + + // 3. Select Claude Code from the dropdown + const claudeOption = page.locator('button:has-text("Claude Code")'); + await expect(claudeOption).toBeVisible(); + await claudeOption.click(); + + // 4. Wait for navigation to session page + await page.waitForURL(/\/session\/.+/); + console.log('[Test] Navigated to session page:', page.url()); + + // 5. Wait for the page to load (no loading spinner) + await expect(page.locator('text=Loading')).not.toBeVisible({ timeout: 5000 }); + + // 6. Verify we see the empty message state + await expect(page.locator('text=No messages yet')).toBeVisible(); + + // 7. Type a message in the textarea + const textarea = page.locator('textarea'); + await expect(textarea).toBeVisible(); + await textarea.fill('say hi. respond in a single concise sentence'); + + // 8. Click the send button + const sendButton = page.locator('button[type="submit"]'); + await expect(sendButton).toBeEnabled(); + await sendButton.click(); + + // 9. Verify user message appears immediately (optimistic update) + await expect(page.locator('text=say hi. respond in a single concise sentence')).toBeVisible(); + console.log('[Test] User message displayed'); + + // 10. Verify thinking indicator appears immediately after sending + // The thinking indicator is a .rounded-lg.border div with bouncing dots inside + const bouncingDots = page.locator('.animate-bounce'); + + // Thinking indicator should appear almost immediately (within 2 seconds) + await expect(bouncingDots.first()).toBeVisible({ timeout: 2000 }); + console.log('[Test] Thinking indicator appeared immediately'); + + // 11. Wait for streaming to complete - progress indicator should disappear + // The streaming indicator has animate-bounce dots and animate-pulse cursor + // Only look for pulsing cursor inside markdown-content (not the header status indicator) + const pulsingCursor = page.locator('.markdown-content .animate-pulse'); + + // Wait for streaming indicators to disappear (they should be gone after message-stop) + await expect(bouncingDots).toHaveCount(0, { timeout: 30000 }); + await expect(pulsingCursor).toHaveCount(0, { timeout: 30000 }); + console.log('[Test] Streaming complete - progress indicator disappeared'); + + // 12. Verify the response contains some text content + // The assistant message is the last .rounded-lg.border with .markdown-content inside + const assistantMessage = page.locator('.rounded-lg.border').filter({ + has: page.locator('.markdown-content') + }).last(); + const responseText = await assistantMessage.locator('.markdown-content').textContent(); + console.log('[Test] Assistant response text:', responseText); + expect(responseText).toBeTruthy(); + expect(responseText!.length).toBeGreaterThan(0); + + // 13. Verify working directory indicator appears + // The working directory should be captured from the init event and displayed + const workingDirIndicator = page.locator('.font-mono').filter({ hasText: /^\// }).first(); + await expect(workingDirIndicator).toBeVisible({ timeout: 5000 }); + const workingDirText = await workingDirIndicator.textContent(); + console.log('[Test] Working directory displayed:', workingDirText); + expect(workingDirText).toMatch(/^\//); // Should start with / + }); +}); diff --git a/e2e/tests/workflow.spec.ts b/e2e/tests/workflow-opencode.spec.ts similarity index 75% rename from e2e/tests/workflow.spec.ts rename to e2e/tests/workflow-opencode.spec.ts index 7521f48..9069f7d 100644 --- a/e2e/tests/workflow.spec.ts +++ b/e2e/tests/workflow-opencode.spec.ts @@ -1,7 +1,9 @@ import { test, expect } from '@playwright/test'; -test.describe('Chat Workflow', () => { - test('create new chat and send message to Claude', async ({ page }) => { +test.describe('OpenCode Chat Workflow', () => { + // Skip: OpenCode (Go binary) has stdout buffering issues when run as subprocess from Java + // Go binaries ignore stdbuf and require a pseudo-terminal for proper streaming + test.skip('create new chat and send message to OpenCode', async ({ page }) => { // Enable console logging to debug WebSocket issues page.on('console', (msg) => { console.log(`[Browser ${msg.type()}]`, msg.text()); @@ -31,36 +33,41 @@ test.describe('Chat Workflow', () => { await page.goto('/'); await expect(page).toHaveTitle(/Spiceflow/i); - // 2. Click the + button to create a new session + // 2. Click the + button to open new session menu const createButton = page.locator('button[title="New Session"]'); await expect(createButton).toBeVisible(); await createButton.click(); - // 3. Wait for navigation to session page + // 3. Select OpenCode from the dropdown + const opencodeOption = page.locator('button:has-text("OpenCode")'); + await expect(opencodeOption).toBeVisible(); + await opencodeOption.click(); + + // 4. Wait for navigation to session page await page.waitForURL(/\/session\/.+/); console.log('[Test] Navigated to session page:', page.url()); - // 4. Wait for the page to load (no loading spinner) + // 5. Wait for the page to load (no loading spinner) await expect(page.locator('text=Loading')).not.toBeVisible({ timeout: 5000 }); - // 5. Verify we see the empty message state + // 6. Verify we see the empty message state await expect(page.locator('text=No messages yet')).toBeVisible(); - // 6. Type a message in the textarea + // 7. Type a message in the textarea const textarea = page.locator('textarea'); await expect(textarea).toBeVisible(); await textarea.fill('say hi. respond in a single concise sentence'); - // 7. Click the send button + // 8. Click the send button const sendButton = page.locator('button[type="submit"]'); await expect(sendButton).toBeEnabled(); await sendButton.click(); - // 8. Verify user message appears immediately (optimistic update) + // 9. Verify user message appears immediately (optimistic update) await expect(page.locator('text=say hi. respond in a single concise sentence')).toBeVisible(); console.log('[Test] User message displayed'); - // 9. Verify thinking indicator appears immediately after sending + // 10. Verify thinking indicator appears immediately after sending // The assistant bubble with bouncing dots should show right away (isThinking state) const assistantMessage = page.locator('.rounded-lg.border').filter({ has: page.locator('text=Assistant') @@ -75,25 +82,26 @@ test.describe('Chat Workflow', () => { await expect(bouncingDotsInAssistant.first()).toBeVisible({ timeout: 2000 }); console.log('[Test] Bouncing dots visible in thinking state'); - // 10. Wait for streaming to complete - progress indicator should disappear + // 11. Wait for streaming to complete - progress indicator should disappear // The streaming indicator has animate-bounce dots and animate-pulse cursor // Note: With fast responses, the indicator may appear and disappear quickly, // so we just verify it's gone after the response is visible const bouncingDots = page.locator('.animate-bounce'); - const pulsingCursor = page.locator('.animate-pulse'); + // Only look for pulsing cursor inside markdown-content (not the header status indicator) + const pulsingCursor = page.locator('.markdown-content .animate-pulse'); // Wait for streaming indicators to disappear (they should be gone after message-stop) await expect(bouncingDots).toHaveCount(0, { timeout: 30000 }); await expect(pulsingCursor).toHaveCount(0, { timeout: 30000 }); console.log('[Test] Streaming complete - progress indicator disappeared'); - // 11. Verify the response contains some text content + // 12. Verify the response contains some text content const responseText = await assistantMessage.locator('.font-mono').textContent(); console.log('[Test] Assistant response text:', responseText); expect(responseText).toBeTruthy(); expect(responseText!.length).toBeGreaterThan(0); - // 12. Verify working directory indicator appears + // 13. Verify working directory indicator appears // The working directory should be captured from the init event and displayed const workingDirIndicator = page.locator('.font-mono').filter({ hasText: /^\// }).first(); await expect(workingDirIndicator).toBeVisible({ timeout: 5000 }); diff --git a/foo.md b/foo.md new file mode 100644 index 0000000..a249739 --- /dev/null +++ b/foo.md @@ -0,0 +1,5 @@ +# My Haiku: + +Wires cross and spark +Agents dance to their own tune +My wishes fade fast \ No newline at end of file diff --git a/script/dev b/script/dev index 24c4bfb..e86430a 100755 --- a/script/dev +++ b/script/dev @@ -1,7 +1,5 @@ #!/bin/bash -# Development script - runs backend and frontend concurrently - -set -e +# Development script - runs backend REPL with auto-reload and frontend SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(dirname "$SCRIPT_DIR")" @@ -13,21 +11,56 @@ YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color +BACKEND_PID="" +FRONTEND_PID="" +NREPL_PORT=7888 + cleanup() { echo -e "\n${YELLOW}Shutting down...${NC}" - kill $BACKEND_PID 2>/dev/null || true - kill $FRONTEND_PID 2>/dev/null || true + + # Kill backend and all its children + if [ -n "$BACKEND_PID" ]; then + pkill -P $BACKEND_PID 2>/dev/null || true + kill $BACKEND_PID 2>/dev/null || true + fi + + # Kill frontend and all its children + if [ -n "$FRONTEND_PID" ]; then + pkill -P $FRONTEND_PID 2>/dev/null || true + kill $FRONTEND_PID 2>/dev/null || true + fi + + # Also kill any orphaned processes on our ports + fuser -k 3000/tcp 2>/dev/null || true + fuser -k 5173/tcp 2>/dev/null || true + fuser -k $NREPL_PORT/tcp 2>/dev/null || true + + wait 2>/dev/null || true + echo -e "${GREEN}Stopped${NC}" + + # Restore terminal + stty sane 2>/dev/null || true exit 0 } -trap cleanup SIGINT SIGTERM +trap cleanup SIGINT SIGTERM SIGHUP EXIT echo -e "${BLUE}=== Starting Spiceflow Development Environment ===${NC}\n" -# Start backend -echo -e "${GREEN}Starting backend server...${NC}" +# Start backend REPL with auto-reload +echo -e "${GREEN}Starting backend REPL with auto-reload...${NC}" cd "$ROOT_DIR/server" -clj -M:run & + +# Start nREPL server and run (go) to start app with file watcher +clj -M:dev -e " +(require 'nrepl.server) +(def server (nrepl.server/start-server :port $NREPL_PORT)) +(println \"nREPL server started on port $NREPL_PORT\") +(require 'user) +(user/go) +;; Block forever to keep the process running +@(promise) +" & BACKEND_PID=$! # Wait for backend to be ready @@ -36,6 +69,8 @@ until curl -s http://localhost:3000/api/health > /dev/null 2>&1; do sleep 1 done echo -e "${GREEN}Backend ready on http://localhost:3000${NC}" +echo -e "${GREEN}nREPL available on port $NREPL_PORT${NC}" +echo -e "${GREEN}Auto-reload enabled - editing .clj files will trigger reload${NC}" # Start frontend echo -e "${GREEN}Starting frontend server...${NC}" @@ -55,9 +90,11 @@ LOCAL_IP=$(hostname -I | awk '{print $1}') echo -e "\n${BLUE}=== Spiceflow Development Environment Ready ===${NC}" echo -e "${GREEN}Backend:${NC} http://localhost:3000" +echo -e "${GREEN}nREPL:${NC} localhost:$NREPL_PORT" echo -e "${GREEN}Frontend:${NC} https://localhost:5173" echo -e "${GREEN}Phone:${NC} https://${LOCAL_IP}:5173" -echo -e "\nPress Ctrl+C to stop\n" +echo -e "\n${YELLOW}Auto-reload is active. Edit any .clj file to trigger reload.${NC}" +echo -e "Press Ctrl+C to stop\n" # Wait for processes wait diff --git a/script/test b/script/test index 4ad57b4..96120d1 100755 --- a/script/test +++ b/script/test @@ -39,31 +39,35 @@ echo -e "${BLUE}=== Starting Spiceflow Test Environment ===${NC}\n" # Clean up old test database rm -f "$ROOT_DIR/server/test-e2e.db" +# Use different ports for e2e tests (matching playwright.config.ts) +E2E_BACKEND_PORT=3001 +E2E_FRONTEND_PORT=5174 + # Start backend with test database echo -e "${GREEN}Starting backend server with test database...${NC}" cd "$ROOT_DIR/server" -SPICEFLOW_DB="test-e2e.db" clj -M:run & +SPICEFLOW_DB="test-e2e.db" SPICEFLOW_PORT=$E2E_BACKEND_PORT clj -M:run & BACKEND_PID=$! # Wait for backend to be ready echo -e "${YELLOW}Waiting for backend...${NC}" -until curl -s http://localhost:3000/api/health > /dev/null 2>&1; do +until curl -s http://localhost:$E2E_BACKEND_PORT/api/health > /dev/null 2>&1; do sleep 1 done -echo -e "${GREEN}Backend ready on http://localhost:3000${NC}" +echo -e "${GREEN}Backend ready on http://localhost:$E2E_BACKEND_PORT${NC}" # Start frontend echo -e "${GREEN}Starting frontend server...${NC}" cd "$ROOT_DIR/client" -npm run dev -- --port 5173 & +VITE_API_URL="http://localhost:$E2E_BACKEND_PORT" npm run dev -- --port $E2E_FRONTEND_PORT & FRONTEND_PID=$! # Wait for frontend to be ready echo -e "${YELLOW}Waiting for frontend...${NC}" -until curl -sk https://localhost:5173 > /dev/null 2>&1; do +until curl -sk https://localhost:$E2E_FRONTEND_PORT > /dev/null 2>&1; do sleep 1 done -echo -e "${GREEN}Frontend ready on https://localhost:5173${NC}\n" +echo -e "${GREEN}Frontend ready on https://localhost:$E2E_FRONTEND_PORT${NC}\n" # Run e2e tests echo -e "${BLUE}=== Running E2E Tests ===${NC}\n" diff --git a/server/deps.edn b/server/deps.edn index 36449f7..ec07ca6 100644 --- a/server/deps.edn +++ b/server/deps.edn @@ -32,4 +32,9 @@ :main-opts ["-m" "kaocha.runner"]} :repl {:main-opts ["-m" "nrepl.cmdline" "-i"] :extra-deps {nrepl/nrepl {:mvn/version "1.1.0"} - cider/cider-nrepl {:mvn/version "0.44.0"}}}}} + cider/cider-nrepl {:mvn/version "0.44.0"}}} + :dev {:extra-paths ["dev"] + :extra-deps {nrepl/nrepl {:mvn/version "1.1.0"} + cider/cider-nrepl {:mvn/version "0.44.0"} + org.clojure/tools.namespace {:mvn/version "1.5.0"} + hawk/hawk {:mvn/version "0.2.11"}}}}} diff --git a/server/dev/user.clj b/server/dev/user.clj new file mode 100644 index 0000000..2fcb6b2 --- /dev/null +++ b/server/dev/user.clj @@ -0,0 +1,76 @@ +(ns user + (:require [clojure.tools.namespace.repl :as repl] + [clojure.tools.logging :as log] + [mount.core :as mount] + [hawk.core :as hawk])) + +;; Only reload spiceflow namespaces +(repl/set-refresh-dirs "src") + +(defonce watcher (atom nil)) + +(defn start + "Start the application." + [] + (require 'spiceflow.core) + ((resolve 'spiceflow.core/-main))) + +(defn stop + "Stop the application." + [] + (mount/stop)) + +(defn reset + "Stop, reload all changed namespaces, and restart." + [] + (stop) + (repl/refresh :after 'user/start)) + +(defn reload + "Reload all changed namespaces without restarting." + [] + (repl/refresh)) + +(defn reload-all + "Force reload all app namespaces." + [] + (stop) + (repl/refresh-all :after 'user/start)) + +(defn- clj-file? [_ {:keys [file]}] + (and file (.endsWith (.getName file) ".clj"))) + +(defn- on-file-change [_ _] + (log/info "File change detected, reloading namespaces...") + (log/info "Reloading workspaces...") + (try + (reset) + (log/info "Reload complete") + (catch Exception e + (log/error e "Reload failed")))) + +(defn watch + "Start watching src directory for changes and auto-reload." + [] + (when @watcher + (hawk/stop! @watcher)) + (reset! watcher + (hawk/watch! [{:paths ["src"] + :filter clj-file? + :handler on-file-change}])) + (log/info "File watcher started - will auto-reload on .clj changes")) + +(defn unwatch + "Stop the file watcher." + [] + (when @watcher + (hawk/stop! @watcher) + (reset! watcher nil) + (log/info "File watcher stopped"))) + +(defn go + "Start the app and enable auto-reload on file changes." + [] + (start) + (watch) + :ready) diff --git a/server/foo.md b/server/foo.md new file mode 100644 index 0000000..9bdf52b --- /dev/null +++ b/server/foo.md @@ -0,0 +1,5 @@ +# My Haiku + +Code finds its truth +In tests that catch the bugs +Software sleeps sound \ No newline at end of file diff --git a/server/resources/logback.xml b/server/resources/logback.xml index ab0dad2..7882e83 100644 --- a/server/resources/logback.xml +++ b/server/resources/logback.xml @@ -1,15 +1,33 @@ + + + INFO + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - + + + logs/spiceflow.log + + logs/spiceflow.%d{yyyy-MM-dd}.log + 7 + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + - + + diff --git a/server/src/spiceflow/adapters/opencode.clj b/server/src/spiceflow/adapters/opencode.clj index a5aaba8..b51f210 100644 --- a/server/src/spiceflow/adapters/opencode.clj +++ b/server/src/spiceflow/adapters/opencode.clj @@ -1,5 +1,8 @@ (ns spiceflow.adapters.opencode - "Adapter for OpenCode CLI" + "Adapter for OpenCode CLI. + + OpenCode works differently from Claude - it uses a single-shot execution model + where each message spawns a new `opencode run` process with the message as an argument." (:require [spiceflow.adapters.protocol :as proto] [clojure.java.io :as io] [clojure.java.shell :as shell] @@ -60,26 +63,64 @@ (log/warn "Failed to parse session export:" (.getMessage e)) nil))) -(defn- parse-stream-output - "Parse a line of streaming output from OpenCode" +(defn- parse-json-event + "Parse a JSON event from OpenCode's --format json output" [line] (try (when (and line (not (str/blank? line))) - (if (str/starts-with? line "{") - (let [data (json/read-value line mapper)] - (cond - (:content data) {:event :content-delta - :text (:content data)} - (:done data) {:event :message-stop} - (:error data) {:event :error - :message (:error data)} - :else {:raw data})) - ;; Plain text output - {:event :content-delta - :text line})) + (let [data (json/read-value line mapper)] + (log/debug "OpenCode JSON event:" (:type data)) + (case (:type data) + ;; Step start - beginning of a new step + "step_start" {:event :init + :session-id (:sessionID data) + :cwd (get-in data [:part :cwd])} + + ;; Text content - the actual response text + "text" {:event :content-delta + :text (get-in data [:part :text])} + + ;; Tool use events + "tool_start" {:event :tool-start + :tool (get-in data [:part :tool]) + :input (get-in data [:part :input])} + "tool_finish" {:event :tool-result + :tool (get-in data [:part :tool]) + :result (get-in data [:part :result])} + + ;; Step finish - end of current step + "step_finish" {:event :result + :session-id (:sessionID data) + :cost (get-in data [:part :cost]) + :stop-reason (get-in data [:part :reason])} + + ;; Error events + "error" {:event :error + :message (:message data)} + + ;; Permission-related events (if OpenCode has them) + "permission_request" {:event :permission-request + :permission-request {:tools [(get-in data [:part :tool])] + :denials [{:tool (get-in data [:part :tool]) + :input (get-in data [:part :input]) + :description (get-in data [:part :description])}]}} + + ;; Default: pass through as raw (don't emit content-delta for unknown types) + (do + (log/debug "Unknown OpenCode event type:" (:type data)) + {:raw data})))) (catch Exception e - (log/debug "Failed to parse line:" line) - {:event :content-delta :text line}))) + (log/debug "Failed to parse JSON line:" line (.getMessage e)) + ;; If it's not JSON, treat as plain text + (when (and line (not (str/blank? line))) + {:event :content-delta :text (str line "\n")})))) + +(defn- parse-default-output + "Parse default (non-JSON) output from OpenCode" + [line] + (when (and line (not (str/blank? line))) + {:event :content-delta + :text (str line "\n")})) (defrecord OpenCodeAdapter [command] proto/AgentAdapter @@ -92,44 +133,103 @@ [])) (spawn-session [_ session-id opts] - (let [{:keys [working-dir]} opts - args [command "run" "--session" session-id] - pb (ProcessBuilder. args)] - (when working-dir - (.directory pb (io/file working-dir))) - (.redirectErrorStream pb false) - (let [process (.start pb)] - {:process process - :stdin (BufferedWriter. (OutputStreamWriter. (.getOutputStream process) StandardCharsets/UTF_8)) - :stdout (BufferedReader. (InputStreamReader. (.getInputStream process) StandardCharsets/UTF_8)) - :stderr (BufferedReader. (InputStreamReader. (.getErrorStream process) StandardCharsets/UTF_8))}))) + ;; For OpenCode, we don't start a process here - we just create a handle + ;; that stores the configuration. The actual process is started when send-message is called. + (let [{:keys [working-dir allowed-tools]} opts] + {:session-id session-id + :working-dir working-dir + :allowed-tools allowed-tools + :command command + ;; These will be populated when send-message is called + :process nil + :stdout nil + :stderr nil})) - (send-message [_ {:keys [stdin]} message] + (send-message [_ handle message] (try - (.write stdin message) - (.newLine stdin) - (.flush stdin) - true + (let [{:keys [session-id working-dir command allowed-tools]} handle + ;; Build command args + ;; Use --format json for structured output + ;; Build the opencode command string + opencode-cmd (str command " run --format json" + (when session-id (str " --session " session-id)) + " " (pr-str message)) + ;; Wrap with script -qc to create a pseudo-terminal + ;; This forces Go to flush stdout properly (Go binaries ignore stdbuf) + args ["script" "-qc" opencode-cmd "/dev/null"] + _ (log/info "Starting OpenCode with args:" args) + pb (ProcessBuilder. (vec args))] + ;; Set working directory + (when working-dir + (.directory pb (io/file working-dir))) + ;; Don't merge stderr into stdout - keep them separate + (.redirectErrorStream pb false) + + ;; Start the process + (let [process (.start pb) + stdout (BufferedReader. (InputStreamReader. (.getInputStream process) StandardCharsets/UTF_8)) + stderr (BufferedReader. (InputStreamReader. (.getErrorStream process) StandardCharsets/UTF_8))] + ;; Update the handle with the running process + ;; Note: We're mutating the handle here by storing process info + ;; The caller should use the returned handle + (assoc handle + :process process + :stdout stdout + :stderr stderr))) (catch Exception e - (log/error "Failed to send message:" (.getMessage e)) - false))) + (log/error "Failed to start OpenCode process:" (.getMessage e)) + nil))) - (read-stream [this {:keys [stdout]} callback] + (read-stream [this {:keys [stdout stderr process]} callback] + (log/info "read-stream starting, stdout:" (boolean stdout) "process:" (boolean process) "process-alive:" (when process (.isAlive process))) (try + ;; Start a thread to log stderr + (when stderr + (future + (try + (loop [] + (when-let [line (.readLine stderr)] + (log/info "[OpenCode stderr]" line) + (recur))) + (catch Exception e + (log/info "Stderr stream ended:" (.getMessage e)))))) + + ;; Read stdout for JSON events + (log/info "Starting stdout read loop") (loop [] + (log/debug "Waiting for line from stdout...") (when-let [line (.readLine stdout)] - (when-let [parsed (proto/parse-output this line)] - (callback parsed)) - (recur))) + (log/info "[OpenCode stdout]" line) + (let [parsed (proto/parse-output this line)] + (when parsed + (log/info "Parsed event:" (:event parsed)) + (callback parsed)) + ;; Continue reading unless we hit a terminal event + ;; Note: step_finish with reason "tool-calls" is NOT terminal - OpenCode + ;; continues after running tools. Only stop on :error or :result with + ;; a non-tool-calls reason. + (if (or (= :error (:event parsed)) + (and (= :result (:event parsed)) + (not= "tool-calls" (:stop-reason parsed)))) + (log/info "Received terminal event, stopping stream read. stop-reason:" (:stop-reason parsed)) + (recur))))) + (log/info "stdout read loop ended (nil line)") + + ;; Wait for process to complete + (when process + (log/info "Waiting for process to complete") + (.waitFor process) + (log/info "Process completed with exit code:" (.exitValue process))) + (catch Exception e - (log/debug "Stream ended:" (.getMessage e))))) + (log/error "Stream error:" (.getMessage e) (class e))))) (kill-process [_ {:keys [process]}] (when process (.destroyForcibly process))) (parse-output [_ line] - (parse-stream-output line))) + (parse-json-event line))) (defn create-adapter "Create an OpenCode adapter" diff --git a/server/src/spiceflow/api/routes.clj b/server/src/spiceflow/api/routes.clj index 50eaf65..017e176 100644 --- a/server/src/spiceflow/api/routes.clj +++ b/server/src/spiceflow/api/routes.clj @@ -41,6 +41,7 @@ [store] (fn [request] (let [body (:body request)] + (log/debug "API request: create-session" {:body body}) (if (db/valid-session? body) (let [session (db/save-session store body)] (-> (json-response session) @@ -51,6 +52,7 @@ [store] (fn [request] (let [id (get-in request [:path-params :id])] + (log/debug "API request: delete-session" {:session-id id}) (if (db/get-session store id) (do (manager/stop-session store id) @@ -63,8 +65,9 @@ (fn [request] (let [id (get-in request [:path-params :id]) body (:body request)] + (log/debug "API request: update-session" {:session-id id :body body}) (if (db/get-session store id) - (let [updated (db/update-session store id (select-keys body [:title]))] + (let [updated (db/update-session store id (select-keys body [:title :auto-accept-edits]))] (json-response updated)) (error-response 404 "Session not found"))))) @@ -73,6 +76,7 @@ (fn [request] (let [id (get-in request [:path-params :id]) message (get-in request [:body :message])] + (log/debug "API request: send-message" {:session-id id :message message}) (if-let [session (db/get-session store id)] (try ;; Send message and start streaming in a separate thread @@ -101,6 +105,7 @@ (let [id (get-in request [:path-params :id]) {:keys [response message]} (:body request) response-type (keyword response)] + (log/debug "API request: permission-response" {:session-id id :response response :message message}) (if-let [session (db/get-session store id)] (if (#{:accept :deny :steer} response-type) (try diff --git a/server/src/spiceflow/api/websocket.clj b/server/src/spiceflow/api/websocket.clj index d2f80d6..0e0366e 100644 --- a/server/src/spiceflow/api/websocket.clj +++ b/server/src/spiceflow/api/websocket.clj @@ -8,6 +8,14 @@ (def ^:private mapper (json/object-mapper {:encode-key-fn name :decode-key-fn keyword})) +;; Function to get pending permission for a session (set by core.clj) +(defonce ^:private pending-permission-fn (atom nil)) + +(defn set-pending-permission-fn! + "Set the function to retrieve pending permissions for a session" + [f] + (reset! pending-permission-fn f)) + ;; Connected WebSocket sessions: session-id -> #{sockets} (defonce ^:private connections (ConcurrentHashMap.)) @@ -27,7 +35,8 @@ (defn broadcast-to-session "Broadcast an event to all WebSocket connections subscribed to a session" [session-id event] - (log/debug "Broadcasting to session:" session-id "event:" event) + (log/debug "Broadcasting to session:" session-id "event-type:" (:event event)) + (log/debug "Broadcast full event data:" (pr-str event)) (when-let [subscribers (.get connections session-id)] (let [message (assoc event :session-id session-id)] (doseq [socket subscribers] @@ -47,7 +56,14 @@ (let [new-set (ConcurrentHashMap/newKeySet)] (.putIfAbsent connections session-id new-set) (or (.get connections session-id) new-set)))] - (.add subscribers socket))) + (.add subscribers socket) + ;; Send any pending permission request immediately after subscribing + (when-let [get-pending @pending-permission-fn] + (when-let [pending (get-pending session-id)] + (log/debug "Sending pending permission to newly subscribed client:" pending) + (send-to-ws socket {:event :permission-request + :permission-request pending + :session-id session-id}))))) (defn- unsubscribe-from-session "Unsubscribe a WebSocket socket from a session" diff --git a/server/src/spiceflow/core.clj b/server/src/spiceflow/core.clj index b893b85..29a469b 100644 --- a/server/src/spiceflow/core.clj +++ b/server/src/spiceflow/core.clj @@ -25,6 +25,8 @@ (defstate server :start (let [port (get-in config/config [:server :port] 3000) host (get-in config/config [:server :host] "0.0.0.0") + ;; Wire up pending permission function for WebSocket (partially apply store) + _ (ws/set-pending-permission-fn! (partial manager/get-pending-permission store)) api-app (routes/create-app store ws/broadcast-to-session) ;; Wrap the app to handle WebSocket upgrades on /api/ws app (fn [request] diff --git a/server/src/spiceflow/db/memory.clj b/server/src/spiceflow/db/memory.clj index 00e8ad7..d8df5ca 100644 --- a/server/src/spiceflow/db/memory.clj +++ b/server/src/spiceflow/db/memory.clj @@ -65,6 +65,10 @@ new-message)) (get-message [_ id] + (get @messages id)) + + (update-message [_ id data] + (swap! messages update id merge data) (get @messages id))) (defn create-store diff --git a/server/src/spiceflow/db/protocol.clj b/server/src/spiceflow/db/protocol.clj index dfa6043..35799fc 100644 --- a/server/src/spiceflow/db/protocol.clj +++ b/server/src/spiceflow/db/protocol.clj @@ -21,7 +21,9 @@ (save-message [this message] "Save a new message. Returns the saved message with ID.") (get-message [this id] - "Get a single message by ID")) + "Get a single message by ID") + (update-message [this id data] + "Update message fields. Returns updated message.")) (defn valid-session? "Validate session data has required fields" diff --git a/server/src/spiceflow/db/sqlite.clj b/server/src/spiceflow/db/sqlite.clj index f00b1fb..7943fcb 100644 --- a/server/src/spiceflow/db/sqlite.clj +++ b/server/src/spiceflow/db/sqlite.clj @@ -16,6 +16,17 @@ (defn- now-iso [] (.toString (Instant/now))) +;; Status ID constants for foreign key references +(def status-ids + {:idle 1 + :processing 2 + :awaiting-permission 3}) + +(def status-names + {1 :idle + 2 :processing + 3 :awaiting-permission}) + (defn- row->session "Convert a database row to a session map" [row] @@ -43,14 +54,24 @@ (defn- session->row "Convert a session map to database columns" - [{:keys [id provider external-id title working-dir status]}] + [{:keys [id provider external-id title working-dir spawn-dir status pending-permission auto-accept-edits]}] (cond-> {} id (assoc :id id) provider (assoc :provider (name provider)) external-id (assoc :external_id external-id) title (assoc :title title) working-dir (assoc :working_dir working-dir) - status (assoc :status (name status)))) + spawn-dir (assoc :spawn_dir spawn-dir) + status (assoc :status_id (get status-ids (keyword status) 1)) + ;; Handle pending-permission: can be a map (to serialize) or :clear (to set null) + (some? pending-permission) + (assoc :pending_permission + (if (= :clear pending-permission) + nil + (json/write-value-as-string pending-permission))) + ;; Handle auto-accept-edits as integer (0 or 1) + (some? auto-accept-edits) + (assoc :auto_accept_edits (if auto-accept-edits 1 0)))) (defn- message->row "Convert a message map to database columns" @@ -70,14 +91,18 @@ ["SELECT * FROM sessions ORDER BY updated_at DESC"] {:builder-fn rs/as-unqualified-kebab-maps})] (mapv (fn [row] - {:id (:id row) - :provider (keyword (:provider row)) - :external-id (:external-id row) - :title (:title row) - :working-dir (:working-dir row) - :status (keyword (or (:status row) "idle")) - :created-at (:created-at row) - :updated-at (:updated-at row)}) + (cond-> {:id (:id row) + :provider (keyword (:provider row)) + :external-id (:external-id row) + :title (:title row) + :working-dir (:working-dir row) + :spawn-dir (:spawn-dir row) + :status (get status-names (:status-id row) :idle) + :auto-accept-edits (= 1 (:auto-accept-edits row)) + :created-at (:created-at row) + :updated-at (:updated-at row)} + (:pending-permission row) + (assoc :pending-permission (json/read-value (:pending-permission row) mapper)))) rows))) (get-session [_ id] @@ -85,14 +110,18 @@ ["SELECT * FROM sessions WHERE id = ?" id] {:builder-fn rs/as-unqualified-kebab-maps})] (when row - {:id (:id row) - :provider (keyword (:provider row)) - :external-id (:external-id row) - :title (:title row) - :working-dir (:working-dir row) - :status (keyword (or (:status row) "idle")) - :created-at (:created-at row) - :updated-at (:updated-at row)}))) + (cond-> {:id (:id row) + :provider (keyword (:provider row)) + :external-id (:external-id row) + :title (:title row) + :working-dir (:working-dir row) + :spawn-dir (:spawn-dir row) + :status (get status-names (:status-id row) :idle) + :auto-accept-edits (= 1 (:auto-accept-edits row)) + :created-at (:created-at row) + :updated-at (:updated-at row)} + (:pending-permission row) + (assoc :pending-permission (json/read-value (:pending-permission row) mapper)))))) (save-session [this session] (let [id (or (:id session) (generate-id)) @@ -154,17 +183,28 @@ :content (:content row) :metadata (when-let [m (:metadata row)] (json/read-value m mapper)) - :created-at (:created-at row)})))) + :created-at (:created-at row)}))) + + (update-message [this id data] + (let [row (message->row data)] + (sql/update! datasource :messages row {:id id}) + (proto/get-message this id)))) (def schema "SQLite schema for spiceflow" - ["CREATE TABLE IF NOT EXISTS sessions ( + ["CREATE TABLE IF NOT EXISTS session_statuses ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE + )" + "CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, provider TEXT NOT NULL, external_id TEXT, title TEXT, working_dir TEXT, - status TEXT DEFAULT 'idle', + spawn_dir TEXT, + status_id INTEGER DEFAULT 1 REFERENCES session_statuses(id), + pending_permission TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) )" @@ -178,13 +218,70 @@ )" "CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id)" "CREATE INDEX IF NOT EXISTS idx_sessions_provider ON sessions(provider)" - "CREATE INDEX IF NOT EXISTS idx_sessions_external_id ON sessions(external_id)"]) + "CREATE INDEX IF NOT EXISTS idx_sessions_external_id ON sessions(external_id)" + "CREATE INDEX IF NOT EXISTS idx_sessions_status_id ON sessions(status_id)"]) + +(def migrations + "Database migrations for existing databases" + [;; Add pending_permission column if it doesn't exist + "ALTER TABLE sessions ADD COLUMN pending_permission TEXT" + ;; Add status_id column if it doesn't exist (for migration from TEXT status) + "ALTER TABLE sessions ADD COLUMN status_id INTEGER DEFAULT 1 REFERENCES session_statuses(id)" + ;; Add spawn_dir column - the original directory used when spawning Claude + ;; This is separate from working_dir which tracks current directory + "ALTER TABLE sessions ADD COLUMN spawn_dir TEXT" + ;; Add auto_accept_edits column - when enabled, pre-grants Write/Edit tool permissions + "ALTER TABLE sessions ADD COLUMN auto_accept_edits INTEGER DEFAULT 0"]) + +(defn- seed-statuses! + "Seed the session_statuses table with initial values" + [datasource] + (doseq [[status-name status-id] status-ids] + (try + (jdbc/execute! datasource + ["INSERT OR IGNORE INTO session_statuses (id, name) VALUES (?, ?)" + status-id (name status-name)]) + (catch Exception _ + ;; Ignore - already exists + nil)))) + +(defn- migrate-status-column! + "Migrate existing TEXT status values to status_id foreign key" + [datasource] + ;; Update status_id based on existing status TEXT column + (try + (jdbc/execute! datasource + ["UPDATE sessions SET status_id = CASE status + WHEN 'idle' THEN 1 + WHEN 'running' THEN 2 + WHEN 'processing' THEN 2 + WHEN 'awaiting-permission' THEN 3 + WHEN 'awaiting_permission' THEN 3 + ELSE 1 + END + WHERE status_id IS NULL OR status IS NOT NULL"]) + (catch Exception _ + ;; Ignore - status column may not exist or already migrated + nil))) + +(defn- run-migrations! + "Run migrations, ignoring errors for already-applied migrations" + [datasource] + (doseq [stmt migrations] + (try + (jdbc/execute! datasource [stmt]) + (catch Exception _ + ;; Ignore - migration likely already applied + nil)))) (defn init-schema! "Initialize database schema" [datasource] (doseq [stmt schema] - (jdbc/execute! datasource [stmt]))) + (jdbc/execute! datasource [stmt])) + (seed-statuses! datasource) + (run-migrations! datasource) + (migrate-status-column! datasource)) (defn create-store "Create a SQLite store with the given database path" diff --git a/server/src/spiceflow/session/manager.clj b/server/src/spiceflow/session/manager.clj index d12b678..e2e244f 100644 --- a/server/src/spiceflow/session/manager.clj +++ b/server/src/spiceflow/session/manager.clj @@ -10,9 +10,6 @@ ;; Active process handles for running sessions (defonce ^:private active-processes (ConcurrentHashMap.)) -;; Pending permission requests: session-id -> {:tools [...] :denials [...]} -(defonce ^:private pending-permissions (ConcurrentHashMap.)) - (defn get-adapter "Get the appropriate adapter for a provider" [provider] @@ -35,22 +32,33 @@ (defn start-session "Start a CLI process for a session" [store session-id] + (log/debug "User action: start-session" {:session-id session-id}) (let [session (db/get-session store session-id)] (when-not session (throw (ex-info "Session not found" {:session-id session-id}))) (when (session-running? session-id) (throw (ex-info "Session already running" {:session-id session-id}))) + ;; Use spawn-dir for spawning (this is the original directory the session was created in) + ;; Fall back to working-dir for existing sessions that don't have spawn-dir yet (let [adapter (get-adapter (:provider session)) + spawn-dir (or (:spawn-dir session) (:working-dir session)) + ;; Pre-grant Write/Edit tools if auto-accept-edits is enabled + allowed-tools (when (:auto-accept-edits session) + ["Write" "Edit"]) + _ (log/debug "Starting session with spawn-dir:" spawn-dir "external-id:" (:external-id session) "allowed-tools:" allowed-tools) handle (adapter/spawn-session adapter (:external-id session) - {:working-dir (:working-dir session)})] + (cond-> {:working-dir spawn-dir} + (seq allowed-tools) + (assoc :allowed-tools allowed-tools)))] (.put active-processes session-id handle) - (db/update-session store session-id {:status :running}) + (db/update-session store session-id {:status :processing}) handle))) (defn stop-session "Stop a running CLI process for a session" [store session-id] + (log/debug "User action: stop-session" {:session-id session-id}) (when-let [handle (.remove active-processes session-id)] (let [session (db/get-session store session-id) adapter (get-adapter (:provider session))] @@ -60,6 +68,7 @@ (defn send-message-to-session "Send a message to a running session" [store session-id message] + (log/debug "User action: send-message" {:session-id session-id :message message}) (let [session (db/get-session store session-id) _ (when-not session (throw (ex-info "Session not found" {:session-id session-id}))) @@ -71,40 +80,88 @@ (db/save-message store {:session-id session-id :role :user :content message}) - ;; Send to CLI - (adapter/send-message adapter handle message))) + ;; Send to CLI - for OpenCode, this returns an updated handle with the process + (let [result (adapter/send-message adapter handle message)] + (log/info "send-message result type:" (type result) "has-process:" (boolean (:process result))) + ;; If result is a map with :process, it's an updated handle (OpenCode) + ;; Store it so stream-session-response can use it + (when (and (map? result) (:process result)) + (log/info "Storing updated handle with process for session:" session-id) + (.put active-processes session-id result)) + result))) -;; Permission handling - defined before stream-session-response which uses them +;; Permission handling - persisted to database (defn set-pending-permission - "Store a pending permission request for a session" - [session-id permission-request] - (.put pending-permissions session-id permission-request)) + "Store a pending permission request for a session in the database" + [store session-id permission-request] + (db/update-session store session-id {:pending-permission permission-request})) (defn get-pending-permission - "Get pending permission request for a session" - [session-id] - (.get pending-permissions session-id)) + "Get pending permission request for a session from the database" + [store session-id] + (:pending-permission (db/get-session store session-id))) (defn clear-pending-permission - "Clear pending permission for a session" - [session-id] - (.remove pending-permissions session-id)) + "Clear pending permission for a session in the database" + [store session-id] + (db/update-session store session-id {:pending-permission :clear})) + +(defn- extract-path-from-string + "Extract a Unix path from a string, looking for patterns like /home/user/dir" + [s] + (when (string? s) + ;; Look for absolute paths - match /something/something pattern + ;; Paths typically don't contain spaces, newlines, or common sentence punctuation + (let [path-pattern #"(/(?:home|root|usr|var|tmp|opt|etc|mnt|media|Users)[^\s\n\r\"'<>|:;,!?\[\]{}()]*)" + ;; Also match generic absolute paths that look like directories + generic-pattern #"^(/[^\s\n\r\"'<>|:;,!?\[\]{}()]+)$"] + (or + ;; First try to find a path that looks like a home/project directory + (when-let [match (re-find path-pattern s)] + (if (vector? match) (first match) match)) + ;; If the entire trimmed string is a single absolute path, use it + (when-let [match (re-find generic-pattern (clojure.string/trim s))] + (if (vector? match) (second match) match)))))) + +(defn- extract-working-dir-from-tool-result + "Extract working directory from tool result if it looks like a path. + Tool results from pwd, cd, or bash commands may contain directory paths." + [content] + (when (and (vector? content) (seq content)) + ;; Look through tool results for path-like content + (->> content + (filter #(= "tool_result" (:type %))) + (map :content) + (filter string?) + ;; Check each line of the output for a path + (mapcat clojure.string/split-lines) + (map clojure.string/trim) + (keep extract-path-from-string) + ;; Prefer longer paths (more specific), take the last one found + (sort-by count) + last))) (defn stream-session-response "Stream response from a running session, calling callback for each event" [store session-id callback] + (log/info "stream-session-response starting for session:" session-id) (let [session (db/get-session store session-id) _ (when-not session (throw (ex-info "Session not found" {:session-id session-id}))) handle (get-active-process session-id) + _ (log/info "Got handle for session:" session-id "has-process:" (boolean (:process handle)) "has-stdout:" (boolean (:stdout handle))) _ (when-not handle (throw (ex-info "Session not running" {:session-id session-id}))) adapter (get-adapter (:provider session)) - content-buffer (StringBuilder.)] + content-buffer (StringBuilder.) + last-working-dir (atom nil)] ;; Read stream and accumulate content + (log/info "Starting to read stream for session:" session-id) (adapter/read-stream adapter handle (fn [event] + (log/info "Received event:" (:event event) "text:" (when (:text event) (subs (str (:text event)) 0 (min 50 (count (str (:text event))))))) + (log/debug "Agent response full event:" (pr-str event)) (callback event) ;; Accumulate text content (when-let [text (:text event)] @@ -115,12 +172,24 @@ (not (:external-id session))) (log/debug "Capturing external session-id:" (:session-id event)) (db/update-session store session-id {:external-id (:session-id event)})) - ;; Also capture working directory from cwd - (when (and (:cwd event) - (or (nil? (:working-dir session)) - (empty? (:working-dir session)))) - (log/debug "Capturing working directory:" (:cwd event)) - (db/update-session store session-id {:working-dir (:cwd event)}))) + ;; Capture spawn-dir from init cwd (only for new sessions that don't have it) + ;; This is the directory where Claude's project is, used for resuming + (when (and (:cwd event) (not (:spawn-dir session))) + (log/debug "Capturing spawn-dir from init:" (:cwd event)) + (db/update-session store session-id {:spawn-dir (:cwd event)})) + ;; Also set working directory from init cwd + (when (:cwd event) + (log/debug "Capturing working directory from init:" (:cwd event)) + (reset! last-working-dir (:cwd event)))) + ;; Track working directory from tool results (e.g., after cd && pwd) + (when (and (= :user (:role event)) + (vector? (:content event))) + (when-let [dir (extract-working-dir-from-tool-result (:content event))] + (log/info "Detected working directory from tool result:" dir) + (reset! last-working-dir dir) + ;; Emit working-dir-update event so UI can update in real-time + (callback {:event :working-dir-update + :working-dir dir}))) ;; Also capture external-id from result event if not already set (when (and (= :result (:event event)) (:session-id event) @@ -129,21 +198,52 @@ (db/update-session store session-id {:external-id (:session-id event)})) ;; On result event, check for permission requests (when (= :result (:event event)) - (let [content (.toString content-buffer)] - ;; Save accumulated message if any + ;; Use accumulated content, or fall back to result content + ;; (resumed sessions may not stream content-delta events) + (let [accumulated (.toString content-buffer) + result-content (:content event) + content (if (seq accumulated) + accumulated + result-content)] + ;; If no streaming content but result has content, emit it for the client + (when (and (empty? accumulated) (seq result-content)) + (callback {:event :content-delta :text result-content})) + ;; Save message if any content (when (seq content) (db/save-message store {:session-id session-id :role :assistant :content content})) - ;; If there's a permission request, store it and emit event + ;; If there's a permission request, save it as a message and emit event (when-let [perm-req (:permission-request event)] (log/info "Permission request detected:" perm-req) - (set-pending-permission session-id perm-req) - (callback {:event :permission-request - :permission-request perm-req})))))) + ;; Build description for the permission message content + (let [description (->> (:denials perm-req) + (map (fn [{:keys [tool description]}] + (str tool ": " description))) + (clojure.string/join "\n")) + ;; Save permission request as a system message + perm-msg (db/save-message store + {:session-id session-id + :role :system + :content description + :metadata {:type "permission-request" + :denials (:denials perm-req) + :tools (:tools perm-req)}}) + msg-id (:id perm-msg)] + ;; Store pending permission with message ID for later update + (set-pending-permission store session-id + (assoc perm-req :message-id msg-id)) + (callback {:event :permission-request + :permission-request perm-req + :message-id msg-id + :message perm-msg}))))))) + ;; Update session with last known working directory + (when @last-working-dir + (log/info "Updating session working directory to:" @last-working-dir) + (db/update-session store session-id {:working-dir @last-working-dir})) ;; Update session status when stream ends ;; If there's a pending permission, set status to awaiting-permission - (let [new-status (if (get-pending-permission session-id) + (let [new-status (if (get-pending-permission store session-id) :awaiting-permission :idle)] (db/update-session store session-id {:status new-status})) @@ -163,18 +263,27 @@ response-type: :accept, :deny, or :steer message: optional message for :deny or :steer responses" [store session-id response-type message] + (log/debug "User action: permission-response" {:session-id session-id :response-type response-type :message message}) (let [session (db/get-session store session-id) _ (when-not session (throw (ex-info "Session not found" {:session-id session-id}))) - pending (get-pending-permission session-id) + pending (get-pending-permission store session-id) _ (when-not pending (throw (ex-info "No pending permission request" {:session-id session-id}))) adapter (get-adapter (:provider session)) - ;; Build spawn options based on response type - opts (cond-> {:working-dir (:working-dir session)} - ;; For :accept, grant the requested tools - (= response-type :accept) - (assoc :allowed-tools (:tools pending))) + ;; Use spawn-dir for spawning, fall back to working-dir for existing sessions + spawn-dir (or (:spawn-dir session) (:working-dir session)) + ;; Auto-accept tools from session setting (always included if enabled) + auto-accept-tools (when (:auto-accept-edits session) + ["Write" "Edit"]) + ;; Tools granted from accepting the permission request + granted-tools (when (= response-type :accept) (:tools pending)) + ;; Combine both sets of allowed tools + all-allowed-tools (seq (distinct (concat auto-accept-tools granted-tools))) + ;; Build spawn options + opts (cond-> {:working-dir spawn-dir} + all-allowed-tools + (assoc :allowed-tools (vec all-allowed-tools))) ;; Determine the message to send send-msg (case response-type :accept "continue" @@ -184,11 +293,18 @@ handle (adapter/spawn-session adapter (:external-id session) opts)] + ;; Update the permission message with the resolution status + (when-let [msg-id (:message-id pending)] + (let [current-msg (db/get-message store msg-id) + current-metadata (or (:metadata current-msg) {}) + status (name response-type)] + (db/update-message store msg-id + {:metadata (assoc current-metadata :status status)}))) ;; Clear pending permission - (clear-pending-permission session-id) + (clear-pending-permission store session-id) ;; Store new process handle (.put active-processes session-id handle) - (db/update-session store session-id {:status :running}) + (db/update-session store session-id {:status :processing}) ;; Send the response message (adapter/send-message adapter handle send-msg) handle)) diff --git a/server/test-opencode.md b/server/test-opencode.md new file mode 100644 index 0000000..fabf735 --- /dev/null +++ b/server/test-opencode.md @@ -0,0 +1 @@ +Hello from OpenCode test \ No newline at end of file diff --git a/test-opencode.md b/test-opencode.md new file mode 100644 index 0000000..fabf735 --- /dev/null +++ b/test-opencode.md @@ -0,0 +1 @@ +Hello from OpenCode test \ No newline at end of file